diff --git a/src/__tests__/request-id-validation.test.ts b/src/__tests__/request-id-validation.test.ts new file mode 100644 index 000000000..def2df044 --- /dev/null +++ b/src/__tests__/request-id-validation.test.ts @@ -0,0 +1,91 @@ +/** + * An incoming `x-request-id` is client-controlled. It is echoed on the + * response and written into every log line of the request, so it is adopted + * only when it is a plain correlation token — `^[A-Za-z0-9-]{8,64}$` — and + * replaced with a fresh id otherwise. Both places that read it are covered: + * the proxy (every proxied path) and `apiHandler` (which also serves the + * routes the proxy matcher leaves out). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +vi.mock("@/lib/process-type", () => ({ + shouldRunWeb: () => true, + shouldRunWorker: () => true, + getProcessType: () => "all", +})); + +import { proxy } from "../proxy"; +import { apiHandler } from "@/lib/api-handler"; +import { getEvent } from "@/lib/logging/context"; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +const VALID = ["3f9a2c1b", "abcDEF12-3456-7890", "a".repeat(64)]; +const INVALID = [ + "short", + "a".repeat(65), + "id with spaces", + "id_underscore_x", + "", + 'x" level="error', + "ümläut-id-1234", +]; + +beforeEach(() => { + vi.stubEnv("NODE_ENV", "production"); +}); +afterEach(() => { + vi.unstubAllEnvs(); +}); + +function proxied(requestId: string): NextRequest { + return new NextRequest("http://localhost/api/version", { + headers: { "x-request-id": requestId }, + }); +} + +describe("proxy: incoming x-request-id", () => { + it.each(VALID)("adopts the well-formed id %s", (id) => { + const res = proxy(proxied(id)); + expect(res.headers.get("x-request-id")).toBe(id); + }); + + it.each(INVALID)("replaces the malformed id %j with a fresh one", (id) => { + const res = proxy(proxied(id)); + const echoed = res.headers.get("x-request-id"); + expect(echoed).not.toBe(id); + expect(echoed).toMatch(UUID); + // The rewritten request header the route sees carries the same fresh id. + expect(res.headers.get("x-middleware-request-x-request-id")).toBe(echoed); + }); +}); + +describe("apiHandler: incoming x-request-id", () => { + function call(id: string) { + let logged: string | undefined; + const wrapped = apiHandler(async (_request: NextRequest) => { + logged = getEvent()?.getRequestId(); + return NextResponse.json({ data: "ok" }); + }); + return wrapped( + new NextRequest("http://localhost/api/measurements/batch", { + method: "POST", + headers: { "x-request-id": id }, + }), + ).then((res) => ({ res, logged })); + } + + it.each(VALID)("logs and echoes the well-formed id %s", async (id) => { + const { res, logged } = await call(id); + expect(logged).toBe(id); + expect(res.headers.get("x-request-id")).toBe(id); + }); + + it.each(INVALID)("never logs or echoes the malformed id %j", async (id) => { + const { res, logged } = await call(id); + expect(logged).not.toBe(id); + expect(logged).toMatch(UUID); + expect(res.headers.get("x-request-id")).toBe(logged); + }); +}); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 7c6a1603b..c837aec0f 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -54,6 +54,7 @@ import { attachRecordContextEcho, } from "./sharing/record-session-fence"; import { refuseOnKeyMismatch } from "./boot/key-mismatch-refusal"; +import { acceptIncomingRequestId } from "./http/request-id"; import { capturedRateLimit, rateLimitResponseHeaders, @@ -229,11 +230,10 @@ export function apiHandler Promise>( const evt = new WideEventBuilder("http"); - // Propagate x-request-id if present - const incomingRequestId = safeRequestProp( - request, - (r) => r.headers.get("x-request-id"), - null, + // Propagate x-request-id when it is a well-formed token. A malformed one + // never reaches the log or the response; the event keeps its fresh id. + const incomingRequestId = acceptIncomingRequestId( + safeRequestProp(request, (r) => r.headers.get("x-request-id"), null), ); if (incomingRequestId) evt.setRequestId(incomingRequestId); diff --git a/src/lib/http/request-id.ts b/src/lib/http/request-id.ts new file mode 100644 index 000000000..51754621d --- /dev/null +++ b/src/lib/http/request-id.ts @@ -0,0 +1,26 @@ +/** + * Incoming `x-request-id` acceptance. + * + * The header is client-controlled, and the value is echoed on the response + * and written into every log line of the request. Only a plain correlation + * token is adopted — letters, digits and hyphens, 8 to 64 characters, which + * covers a UUID and every proxy-generated id in use — so a caller cannot + * inject quotes, markup or log-shaped text through it. Anything else is + * dropped and the request gets a fresh id. + * + * Edge-safe: no Node imports, because `src/proxy.ts` reads it too. + */ +export const REQUEST_ID_PATTERN = /^[A-Za-z0-9-]{8,64}$/; + +/** The incoming id when it is well-formed, else `null`. */ +export function acceptIncomingRequestId( + value: string | null | undefined, +): string | null { + if (typeof value !== "string") return null; + return REQUEST_ID_PATTERN.test(value) ? value : null; +} + +/** The incoming id when it is well-formed, else a fresh UUID. */ +export function resolveRequestId(value: string | null | undefined): string { + return acceptIncomingRequestId(value) ?? crypto.randomUUID(); +} diff --git a/src/proxy.ts b/src/proxy.ts index 3389400b3..85bc55282 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -7,6 +7,7 @@ import { } from "@/lib/http/retired-routes"; import { setBaselineSecurityHeaders } from "@/lib/http/proxy-bypass-routes"; import { DEMO_REFUSAL_BODY } from "@/lib/demo-refusal"; +import { resolveRequestId } from "@/lib/http/request-id"; /** * Paths that do NOT require a session cookie (public pages + external webhooks). @@ -568,8 +569,10 @@ export function proxy(request: NextRequest) { } } - // Generate or propagate x-request-id for request correlation - const requestId = request.headers.get("x-request-id") || crypto.randomUUID(); + // Generate or propagate x-request-id for request correlation. An incoming + // id is adopted only when it is a plain token (`resolveRequestId`); it is + // echoed and logged, so anything else is replaced with a fresh one. + const requestId = resolveRequestId(request.headers.get("x-request-id")); // 128 bits of random, base64-encoded. randomUUID().toString() only carries // ~122 bits and has a predictable structure that base64-encodes to a