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:
49
packages/core-shared/src/trpc/context.test.ts
Normal file
49
packages/core-shared/src/trpc/context.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>>;
|
||||
|
||||
Reference in New Issue
Block a user