fix(http): adopt an incoming x-request-id only when it is a plain token

The header is echoed on the response and written into every log line. Both
readers (the proxy and apiHandler) now accept it only when it matches
^[A-Za-z0-9-]{8,64}$ and otherwise use a fresh id.
This commit is contained in:
Marc-André Bombeck committed 2026-10-15 20:24:01 +02:00
1 parent f72ec02484
commit 9ee89f2455
4 files changed
+127 -7

No files matched your search

@@ -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",
"<script>alert(1)</script>",
'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);
});
});
+5 -5
View File
@@ -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<T extends (...args: any[]) => Promise<Response>>(
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);
+26
View File
@@ -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();
}
+5 -2
View File
@@ -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