fix(auth): derive clientIp server-side, drop it from sign-in input

clientIp was part of the public signInInputSchema, so any client could
spoof its own rate-limit bucket or dodge IP throttling entirely (audit
finding B2). The schema no longer carries it (strict parsing rejects it
with BAD_REQUEST); instead the web-next tRPC fetch adapter derives it in
createTrpcContext from x-forwarded-for (first hop) / x-real-ip — trust
caveat documented — and the router threads ctx.clientIp to the
controller as a second, server-only argument typed outside the input
schema (SignInRequestContext).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 17:28:56 +02:00
parent bae2686832
commit b66759a1ab
8 changed files with 207 additions and 14 deletions

View File

@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { clientIpFromHeaders, createTrpcContext } from "@/trpc/context";
describe("clientIpFromHeaders", () => {
it("takes the first x-forwarded-for hop", () => {
const headers = new Headers({
"x-forwarded-for": "203.0.113.7, 10.0.0.1, 10.0.0.2",
});
expect(clientIpFromHeaders(headers)).toBe("203.0.113.7");
});
it("trims whitespace around the first hop", () => {
const headers = new Headers({
"x-forwarded-for": " 203.0.113.7 , 10.0.0.1",
});
expect(clientIpFromHeaders(headers)).toBe("203.0.113.7");
});
it("falls back to x-real-ip when x-forwarded-for is absent", () => {
const headers = new Headers({ "x-real-ip": "198.51.100.4" });
expect(clientIpFromHeaders(headers)).toBe("198.51.100.4");
});
it("returns undefined when neither header is present", () => {
expect(clientIpFromHeaders(new Headers())).toBeUndefined();
});
it("returns undefined for empty header values", () => {
const headers = new Headers({ "x-forwarded-for": " ", "x-real-ip": "" });
expect(clientIpFromHeaders(headers)).toBeUndefined();
});
});
describe("createTrpcContext", () => {
it("attaches the derived clientIp from the request", async () => {
const req = new Request("https://example.test/api/trpc", {
headers: { "x-forwarded-for": "203.0.113.7" },
});
await expect(createTrpcContext(req)).resolves.toEqual({
clientIp: "203.0.113.7",
});
});
it("yields an undefined clientIp without a request", async () => {
await expect(createTrpcContext()).resolves.toEqual({
clientIp: undefined,
});
});
});

View File

@@ -1,5 +1,33 @@
export async function createTrpcContext() {
return {};
/**
* Derive the client IP from reverse-proxy headers.
*
* TRUST CAVEAT (audit finding B2): `x-forwarded-for` and `x-real-ip` are
* ordinary request headers. They are only trustworthy when the app runs
* behind a proxy/load balancer that overwrites (or verifiably appends to)
* them on every request. Exposed directly to the internet, a client can
* spoof them; deployments that need a hard guarantee must read the socket
* address at their edge and strip inbound copies of these headers.
*
* We take the FIRST `x-forwarded-for` entry — the client as reported by the
* first (trusted) hop — falling back to `x-real-ip`.
*/
export function clientIpFromHeaders(headers: Headers): string | undefined {
const forwarded = headers.get("x-forwarded-for");
const firstHop = forwarded?.split(",")[0]?.trim();
if (firstHop) return firstHop;
const realIp = headers.get("x-real-ip")?.trim();
return realIp || undefined;
}
/**
* Build the per-request tRPC context. Pass the adapter's incoming fetch
* `Request` so server-derived fields (currently `clientIp`) are attached —
* procedures must never trust client-supplied equivalents (B2).
*/
export async function createTrpcContext(req?: Request) {
return {
clientIp: req ? clientIpFromHeaders(req.headers) : undefined,
};
}
export type TrpcContext = Awaited<ReturnType<typeof createTrpcContext>>;