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

@@ -1,12 +1,15 @@
import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@repo/core-api"; import { appRouter } from "@repo/core-api";
import { createTrpcContext } from "@repo/core-shared/trpc/context";
const handler = async (req: Request) => { const handler = async (req: Request) => {
return fetchRequestHandler({ return fetchRequestHandler({
endpoint: "/api/trpc", endpoint: "/api/trpc",
req, req,
router: appRouter, router: appRouter,
createContext: () => ({}), // Threads server-derived fields (clientIp from proxy headers — see the
// trust caveat in core-shared/trpc/context.ts) into every procedure (B2).
createContext: () => createTrpcContext(req),
}); });
}; };

View File

@@ -11,15 +11,27 @@ import type { IUsersRepository } from "../repositories/users.repository.interfac
import type { IAuthenticationService } from "../services/authentication.service.interface"; import type { IAuthenticationService } from "../services/authentication.service.interface";
// ── Input ──────────────────────────────────────────────────────────────── // ── Input ────────────────────────────────────────────────────────────────
// `.strict()` + no clientIp field: a client submitting clientIp is rejected
// at the procedure boundary (audit finding B2).
export const signInInputSchema = z export const signInInputSchema = z
.object({ .object({
username: z.string().min(3).max(31), username: z.string().min(3).max(31),
password: z.string().min(6).max(255), password: z.string().min(6).max(255),
clientIp: z.string().optional(),
}) })
.strict(); .strict();
export type SignInInput = z.infer<typeof signInInputSchema>; export type SignInInput = z.infer<typeof signInInputSchema>;
/**
* Server-derived per-request context, typed OUTSIDE the public input schema
* so it can never be client-supplied (audit finding B2). The tRPC adapter
* derives `clientIp` from trusted proxy headers and the controller threads
* it through; `undefined` means "no proxy header present" and falls into a
* shared bucket.
*/
export type SignInRequestContext = {
clientIp?: string;
};
// ── Output ─────────────────────────────────────────────────────────────── // ── Output ───────────────────────────────────────────────────────────────
export const signInOutputSchema = z.object({ export const signInOutputSchema = z.object({
session: sessionSchema, session: sessionSchema,
@@ -36,7 +48,7 @@ export const signInUseCase =
authenticationService: IAuthenticationService, authenticationService: IAuthenticationService,
rateLimit: IRateLimit, rateLimit: IRateLimit,
) => ) =>
async (input: SignInInput): Promise<SignInOutput> => { async (input: SignInInput & SignInRequestContext): Promise<SignInOutput> => {
const { allowed: ipAllowed } = await rateLimit.consume( const { allowed: ipAllowed } = await rateLimit.consume(
"ip", "ip",
`signIn:ip:${input.clientIp ?? ""}`, `signIn:ip:${input.clientIp ?? ""}`,

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from "vitest"; import { describe, it, expect, beforeEach, vi } from "vitest";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { authRouter } from "@/integrations/api/router"; import { authRouter } from "@/integrations/api/router";
@@ -27,6 +27,45 @@ describe("authRouter", () => {
}); });
expect(result.name).toBe("session"); expect(result.name).toBe("session");
}); });
it("rejects a client-supplied clientIp at the procedure boundary (B2)", async () => {
const caller = authRouter.createCaller({});
try {
await caller.signIn({
username: "alice",
password: "password_alice",
clientIp: "6.6.6.6",
} as never);
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(TRPCError);
expect((e as TRPCError).code).toBe("BAD_REQUEST");
}
});
it("threads ctx.clientIp (server-derived) into the controller (B2)", async () => {
const original = authContainer.get(AUTH_SYMBOLS.ISignInController);
authContainer.unbind(AUTH_SYMBOLS.ISignInController);
const spy = vi.fn(async () => ({
name: "session",
value: "tok",
attributes: {},
}));
authContainer.bind(AUTH_SYMBOLS.ISignInController).toConstantValue(spy);
try {
const caller = authRouter.createCaller({ clientIp: "203.0.113.7" });
await caller.signIn({ username: "alice", password: "password_alice" });
expect(spy).toHaveBeenCalledWith(
{ username: "alice", password: "password_alice" },
{ clientIp: "203.0.113.7" },
);
} finally {
authContainer.unbind(AUTH_SYMBOLS.ISignInController);
authContainer
.bind(AUTH_SYMBOLS.ISignInController)
.toConstantValue(original);
}
});
}); });
describe("authRouter error mapping", () => { describe("authRouter error mapping", () => {

View File

@@ -14,18 +14,29 @@ import type { ISignOutController } from "../../interface-adapters/controllers/si
import { authProcedure } from "./procedures"; import { authProcedure } from "./procedures";
export const authRouter = router({ export const authRouter = router({
signIn: authProcedure.input(signInInputSchema).mutation(({ input }) => { signIn: authProcedure.input(signInInputSchema).mutation(({ input, ctx }) => {
const ctrl = authContainer.get<ISignInController>(AUTH_SYMBOLS.ISignInController); const ctrl = authContainer.get<ISignInController>(
return ctrl(input); AUTH_SYMBOLS.ISignInController,
);
// clientIp is derived server-side by the adapter's createContext (from
// trusted proxy headers) — never from the client payload; the strict
// input schema rejects a client-supplied clientIp outright (B2). Same
// ctx-cast pattern as the dsr router until the shared t is context-typed.
const { clientIp } = ctx as { clientIp?: string };
return ctrl(input, { clientIp });
}), }),
signUp: authProcedure.input(signUpInputSchema).mutation(({ input }) => { signUp: authProcedure.input(signUpInputSchema).mutation(({ input }) => {
const ctrl = authContainer.get<ISignUpController>(AUTH_SYMBOLS.ISignUpController); const ctrl = authContainer.get<ISignUpController>(
AUTH_SYMBOLS.ISignUpController,
);
return ctrl(input); return ctrl(input);
}), }),
signOut: authProcedure.input(signOutInputSchema).mutation(({ input }) => { signOut: authProcedure.input(signOutInputSchema).mutation(({ input }) => {
const ctrl = authContainer.get<ISignOutController>(AUTH_SYMBOLS.ISignOutController); const ctrl = authContainer.get<ISignOutController>(
AUTH_SYMBOLS.ISignOutController,
);
return ctrl(input); return ctrl(input);
}), }),
}); });

View File

@@ -6,6 +6,7 @@ import { MockAuthenticationService } from "@/infrastructure/services/authenticat
import { InputParseError } from "@/entities/errors/common"; import { InputParseError } from "@/entities/errors/common";
import { userFactory } from "@/__factories__/user.factory"; import { userFactory } from "@/__factories__/user.factory";
import { NoopRateLimit } from "@repo/core-shared/rate-limit"; import { NoopRateLimit } from "@repo/core-shared/rate-limit";
import { RecordingRateLimit } from "@repo/core-testing/rate-limit";
describe("signInController", () => { describe("signInController", () => {
it("returns a cookie on successful sign-in", async () => { it("returns a cookie on successful sign-in", async () => {
@@ -28,6 +29,45 @@ describe("signInController", () => {
expect(result.value).toBeTruthy(); expect(result.value).toBeTruthy();
}); });
it("threads the server-derived clientIp into the use case (B2)", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const rl = new RecordingRateLimit();
await users.createUser(
userFactory.build({
username: "alice",
passwordHash: "hashed_testpassword",
}),
);
const controller = signInController(signInUseCase(users, auth, rl));
await controller(
{ username: "alice", password: "testpassword" },
{ clientIp: "203.0.113.7" },
);
expect(rl.consumeCalls[0]).toMatchObject({
budgetName: "ip",
key: "signIn:ip:203.0.113.7",
});
});
it("rejects clientIp inside the client payload (strict schema, B2)", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const controller = signInController(
signInUseCase(users, auth, new NoopRateLimit()),
);
await expect(
controller({
username: "alice",
password: "testpassword",
clientIp: "6.6.6.6",
}),
).rejects.toBeInstanceOf(InputParseError);
});
it("throws InputParseError on invalid input", async () => { it("throws InputParseError on invalid input", async () => {
const users = new MockUsersRepository([]); const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users); const auth = new MockAuthenticationService(users);

View File

@@ -3,6 +3,7 @@ import {
signInInputSchema, signInInputSchema,
type ISignInUseCase, type ISignInUseCase,
type SignInOutput, type SignInOutput,
type SignInRequestContext,
} from "../../application/use-cases/sign-in.use-case"; } from "../../application/use-cases/sign-in.use-case";
function presenter(value: SignInOutput) { function presenter(value: SignInOutput) {
@@ -13,11 +14,21 @@ export type ISignInController = ReturnType<typeof signInController>;
export const signInController = export const signInController =
(signInUseCase: ISignInUseCase) => (signInUseCase: ISignInUseCase) =>
async (input: unknown): Promise<ReturnType<typeof presenter>> => { async (
input: unknown,
// Server-derived, never part of the client-facing input schema (B2):
// the tRPC adapter builds it from trusted proxy headers.
requestContext?: SignInRequestContext,
): Promise<ReturnType<typeof presenter>> => {
const parsed = signInInputSchema.safeParse(input); const parsed = signInInputSchema.safeParse(input);
if (!parsed.success) { if (!parsed.success) {
throw new InputParseError("Invalid sign-in input", { cause: parsed.error }); throw new InputParseError("Invalid sign-in input", {
cause: parsed.error,
});
} }
const result = await signInUseCase(parsed.data); const result = await signInUseCase({
...parsed.data,
clientIp: requestContext?.clientIp,
});
return presenter(result); return presenter(result);
}; };

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>>; export type TrpcContext = Awaited<ReturnType<typeof createTrpcContext>>;