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