Replace Better Auth's default trust of a caller-controlled X-Forwarded-For with a platform-owned client-address resolution boundary: direct mode (the safe default) trusts only the real socket peer, and an explicit trusted-proxy mode requires a header name, a concrete trusted-proxy list, and only honors a forwarded chain from an actual trusted peer. This closes a rate-limit bypass where rotating a spoofed header let a caller choose a fresh bucket on every request. Public sign-up is also disabled by default and requires an explicit typed opt-in, using Better Auth's own disableSignUp gate so no email-existence leak or second signup path is introduced. Co-Authored-By: Claude Sonnet 5 <[email protected]>
114 lines
4.9 KiB
TypeScript
114 lines
4.9 KiB
TypeScript
import { TRUSTED_CLIENT_ADDRESS_HEADER } from "@ekklesia/core/identity";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE,
|
|
createClientAddressResolver,
|
|
wrapFetchWithClientAddress,
|
|
} from "../src/client-address.js";
|
|
|
|
function fakeEnv(remoteAddress: string | undefined) {
|
|
return {
|
|
incoming: { socket: { remoteAddress } },
|
|
outgoing: {},
|
|
} as unknown as Parameters<typeof wrapFetchWithClientAddress>[0] extends (
|
|
request: Request,
|
|
env: infer Env,
|
|
) => unknown
|
|
? Env
|
|
: never;
|
|
}
|
|
|
|
describe("wrapFetchWithClientAddress", () => {
|
|
it("resolves the trusted address from the real socket peer and sets the internal header before calling the wrapped callback", async () => {
|
|
const resolver = createClientAddressResolver({ mode: "direct" });
|
|
const wrappedFetch = vi.fn().mockResolvedValue(new Response("ok"));
|
|
const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver);
|
|
|
|
const request = new Request("https://example.test/api/auth/sign-in/email");
|
|
await wrapped(request, fakeEnv("203.0.113.9"));
|
|
|
|
expect(wrappedFetch).toHaveBeenCalledTimes(1);
|
|
const [forwardedRequest] = wrappedFetch.mock.calls[0] as [Request];
|
|
expect(forwardedRequest.headers.get(TRUSTED_CLIENT_ADDRESS_HEADER)).toBe("203.0.113.9");
|
|
});
|
|
|
|
it("overwrites — never appends to — a caller-supplied copy of the internal header", async () => {
|
|
const resolver = createClientAddressResolver({ mode: "direct" });
|
|
const wrappedFetch = vi.fn().mockResolvedValue(new Response("ok"));
|
|
const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver);
|
|
|
|
const request = new Request("https://example.test/api/auth/sign-in/email", {
|
|
headers: { [TRUSTED_CLIENT_ADDRESS_HEADER]: "203.0.113.250" },
|
|
});
|
|
await wrapped(request, fakeEnv("192.0.2.1"));
|
|
|
|
const [forwardedRequest] = wrappedFetch.mock.calls[0] as [Request];
|
|
expect(forwardedRequest.headers.get(TRUSTED_CLIENT_ADDRESS_HEADER)).toBe("192.0.2.1");
|
|
// A single value, never a comma-joined "caller-value, resolved-value" — set(), not append().
|
|
expect(forwardedRequest.headers.get(TRUSTED_CLIENT_ADDRESS_HEADER)).not.toContain(",");
|
|
});
|
|
|
|
it("returns a sanitized 503 and never invokes the wrapped callback when resolution fails", async () => {
|
|
const resolver = createClientAddressResolver({ mode: "direct" });
|
|
const wrappedFetch = vi.fn().mockResolvedValue(new Response("should not be reached"));
|
|
const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver);
|
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
|
|
try {
|
|
const response = await wrapped(
|
|
new Request("https://example.test/api/auth/sign-in/email"),
|
|
fakeEnv(undefined),
|
|
);
|
|
|
|
expect(response.status).toBe(503);
|
|
expect(await response.json()).toEqual({ code: "CLIENT_ADDRESS_UNRESOLVED" });
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
expect(wrappedFetch).not.toHaveBeenCalled();
|
|
expect(errorSpy).toHaveBeenCalledWith(CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE);
|
|
} finally {
|
|
errorSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("never logs the peer address, headers, or request path on a resolution failure", async () => {
|
|
const resolver = createClientAddressResolver({ mode: "direct" });
|
|
const wrapped = wrapFetchWithClientAddress(vi.fn(), resolver);
|
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
|
|
try {
|
|
await wrapped(
|
|
new Request("https://example.test/api/auth/sign-in/email", {
|
|
headers: { cookie: "super-secret-session", "x-forwarded-for": "203.0.113.1" },
|
|
}),
|
|
fakeEnv("not-a-valid-ip"),
|
|
);
|
|
|
|
const emitted = errorSpy.mock.calls.flat().map(String).join("\n");
|
|
expect(emitted).not.toContain("super-secret-session");
|
|
expect(emitted).not.toContain("203.0.113.1");
|
|
expect(emitted).not.toContain("sign-in");
|
|
expect(emitted).not.toContain("not-a-valid-ip");
|
|
} finally {
|
|
errorSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("preserves the request method, body, and other headers unrelated to the internal address header", async () => {
|
|
const resolver = createClientAddressResolver({ mode: "direct" });
|
|
const wrappedFetch = vi.fn().mockResolvedValue(new Response("ok"));
|
|
const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver);
|
|
|
|
const request = new Request("https://example.test/api/auth/sign-up/email", {
|
|
body: JSON.stringify({ email: "[email protected]" }),
|
|
headers: { "content-type": "application/json", origin: "https://dashboard.example.test" },
|
|
method: "POST",
|
|
});
|
|
await wrapped(request, fakeEnv("192.0.2.1"));
|
|
|
|
const [forwardedRequest] = wrappedFetch.mock.calls[0] as [Request];
|
|
expect(forwardedRequest.method).toBe("POST");
|
|
expect(forwardedRequest.headers.get("origin")).toBe("https://dashboard.example.test");
|
|
expect(await forwardedRequest.text()).toBe(JSON.stringify({ email: "[email protected]" }));
|
|
});
|
|
});
|