From 40f0a29e0503a4f49fb618ea29e8a03b8a63033d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 13:39:25 +0000 Subject: [PATCH] fix: unique request ids (UUIDv7) instead of Fastify's per-process counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit x-request-id was Fastify's default genReqId ("req-1", "req-2", …): restarted on every process, repeated across replicas, and revealed request volume. Each request now gets a UUIDv7; behind a trusted proxy (TRUST_PROXY >= 1) a well-formed X-Request-ID from it is kept so proxy and api logs share one id. Every response (GraphQL, /health, 413) echoes it, Fastify logs it as reqId and Yoga reuses req.id for its own logs. nginx sets X-Request-ID $request_id. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018jBohnJTiqYe47GyP9DsaG --- deploy/nginx.conf | 2 + docs/09-configuration.md | 2 +- llms-full.txt | 79 ++++++++++++++++++++++++++++++++++++++-- src/request-id.ts | 42 +++++++++++++++++++++ src/server.ts | 7 +++- test/run.ts | 17 +++++++++ 6 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 src/request-id.ts diff --git a/deploy/nginx.conf b/deploy/nginx.conf index fa64e72..e2005a4 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -34,6 +34,8 @@ http { # one trusted hop: TRUST_PROXY=1 on the api reads the right-most entry, the address nginx saw proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + # one id per request (32 random hex), replacing any the client sent; the api (TRUST_PROXY=1) keeps it + proxy_set_header X-Request-ID $request_id; # a replica that is shutting down answers 503: try the next one (nginx never retries a POST that reached a replica) proxy_next_upstream error timeout http_502 http_503; proxy_read_timeout 120s; diff --git a/docs/09-configuration.md b/docs/09-configuration.md index 401470a..792cbb2 100644 --- a/docs/09-configuration.md +++ b/docs/09-configuration.md @@ -33,7 +33,7 @@ variables already set in the real environment win over the file. | `IMPORT_MAX_BYTES` | `15728640` (15 MiB) | Largest partner-lab PDF ([18](18-imports.md)) | | `IMPORT_INLINE_MAX_BYTES` | `5242880` (5 MiB) | Larger partner PDFs are read by the worker when Redis is configured | | `IMPORT_WAIT_MS` | `60000` | How long `importExternalReport` waits for the worker | -| `TRUST_PROXY` | `false` | How many proxies in front of the API append to `X-Forwarded-For`: `true` = 1 (a load balancer), `2` for CDN + load balancer. Login limits and sessions then see the client IP; entries left of the trusted proxies (which any client can send) are ignored | +| `TRUST_PROXY` | `false` | How many proxies in front of the API append to `X-Forwarded-For`: `true` = 1 (a load balancer), `2` for CDN + load balancer. Login limits and sessions then see the client IP; entries left of the trusted proxies (which any client can send) are ignored. Through a trusted proxy, its `X-Request-ID` (when well-formed) becomes the request id; otherwise each request gets a UUIDv7, sent back in `x-request-id` and logged as `reqId` | | `TLS_FINGERPRINT_HEADER` | empty (off) | Header the TLS-terminating proxy sets to the client's JA4 (or JA3) TLS fingerprint, e.g. `x-ja4`. Read only through `TRUST_PROXY` proxies (refused at startup without one); stored hashed on the session, a change audited ([06-auth-and-permissions.md](06-auth-and-permissions.md#tls-fingerprint)) | | `MGPL_SAMPLES` | on in development | Demo report/invoice samples in `documents` | | `SESSION_IDLE_MS` | `43200000` (12 h) | | diff --git a/llms-full.txt b/llms-full.txt index bea637c..c102735 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,6 +1,6 @@ # @mgpl-diagnostics/api — full documentation and source -> Generated by scripts/llms-full.mjs. Do not edit by hand. 103 files. +> Generated by scripts/llms-full.mjs. Do not edit by hand. 104 files. ## Contents @@ -69,6 +69,7 @@ - src/jobs/types.ts - src/lifecycle.ts - src/main.ts +- src/request-id.ts - src/schema/auth.graphql.ts - src/schema/base.graphql.ts - src/schema/billing.graphql.ts @@ -1518,7 +1519,7 @@ variables already set in the real environment win over the file. | `IMPORT_MAX_BYTES` | `15728640` (15 MiB) | Largest partner-lab PDF ([18](18-imports.md)) | | `IMPORT_INLINE_MAX_BYTES` | `5242880` (5 MiB) | Larger partner PDFs are read by the worker when Redis is configured | | `IMPORT_WAIT_MS` | `60000` | How long `importExternalReport` waits for the worker | -| `TRUST_PROXY` | `false` | How many proxies in front of the API append to `X-Forwarded-For`: `true` = 1 (a load balancer), `2` for CDN + load balancer. Login limits and sessions then see the client IP; entries left of the trusted proxies (which any client can send) are ignored | +| `TRUST_PROXY` | `false` | How many proxies in front of the API append to `X-Forwarded-For`: `true` = 1 (a load balancer), `2` for CDN + load balancer. Login limits and sessions then see the client IP; entries left of the trusted proxies (which any client can send) are ignored. Through a trusted proxy, its `X-Request-ID` (when well-formed) becomes the request id; otherwise each request gets a UUIDv7, sent back in `x-request-id` and logged as `reqId` | | `TLS_FINGERPRINT_HEADER` | empty (off) | Header the TLS-terminating proxy sets to the client's JA4 (or JA3) TLS fingerprint, e.g. `x-ja4`. Read only through `TRUST_PROXY` proxies (refused at startup without one); stored hashed on the session, a change audited ([06-auth-and-permissions.md](06-auth-and-permissions.md#tls-fingerprint)) | | `MGPL_SAMPLES` | on in development | Demo report/invoice samples in `documents` | | `SESSION_IDLE_MS` | `43200000` (12 h) | | @@ -8262,6 +8263,56 @@ shutdownOnSignals(app, { ...config.shutdown, log: (m) => console.log(m) }); ``` +--- + +## File: src/request-id.ts + +```ts +import { randomFillSync } from "node:crypto"; +import type { IncomingMessage } from "node:http"; + +/** The header that carries the request id, in both directions. */ +export const REQUEST_ID_HEADER = "x-request-id"; + +/** + * Ids we accept from a trusted proxy: nginx's `$request_id` (32 hex), Envoy's UUID, AWS/GCP trace ids. + * Printable, no spaces, no control characters (the id goes into every log line of the request). + */ +const INCOMING = /^[A-Za-z0-9._:\-]{8,128}$/; + +const HEX = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0")); +const bytes = new Uint8Array(16); + +/** + * A UUIDv7 (RFC 9562): 48-bit Unix time in milliseconds, then 74 random bits. Unique across replicas and + * restarts, sorts by time, and does not reveal how many requests a replica has served (Fastify's default + * was a per-process counter, "req-1", "req-2", …). + */ +export function uuidv7(now = Date.now()): string { + randomFillSync(bytes); + const ms = BigInt(now); + for (let i = 0; i < 6; i++) bytes[i] = Number((ms >> BigInt(8 * (5 - i))) & 0xffn); + bytes[6] = (bytes[6]! & 0x0f) | 0x70; // version 7 + bytes[8] = (bytes[8]! & 0x3f) | 0x80; // variant 10 + let s = ""; + for (let i = 0; i < 16; i++) s += (i === 4 || i === 6 || i === 8 || i === 10 ? "-" : "") + HEX[bytes[i]!]; + return s; +} + +/** + * Fastify's `genReqId`. With a trusted proxy in front (TRUST_PROXY ≥ 1) its X-Request-ID is kept when it + * looks like an id, so the proxy's logs and ours share one id; otherwise (no proxy, missing or malformed + * header) a new UUIDv7. Without a trusted proxy the header is client input and is ignored. + */ +export function requestIdGenerator(trustedHops: number): (req: IncomingMessage) => string { + return (req) => { + const incoming = trustedHops > 0 ? req.headers[REQUEST_ID_HEADER] : undefined; + return typeof incoming === "string" && INCOMING.test(incoming) ? incoming : uuidv7(); + }; +} +``` + + --- ## File: src/schema/auth.graphql.ts @@ -9563,6 +9614,7 @@ import { AUTH_MODE_HEADER } from "./graphql/cookies"; import { allowsLargeBody, costLimit, depthLimit, importBodyLimit } from "./graphql/limits"; import { buildSchema } from "./graphql/schema"; import { isDraining } from "./lifecycle"; +import { REQUEST_ID_HEADER, requestIdGenerator } from "./request-id"; type ServerContext = { req: FastifyRequest; reply: FastifyReply }; @@ -9598,7 +9650,11 @@ function rules(introspection: boolean): Plugin { */ export async function buildServer(container: Container): Promise { const { config } = container; - const app = Fastify({ logger: config.env === "production" ? { level: "info" } : config.env === "test" ? false : { level: "warn" }, bodyLimit: Math.max(config.bodyLimit, importBodyLimit(config.imports.maxBytes)), trustProxy: (_addr: string, hop: number) => hop < config.trustedHops }); + const app = Fastify({ logger: config.env === "production" ? { level: "info" } : config.env === "test" ? false : { level: "warn" }, bodyLimit: Math.max(config.bodyLimit, importBodyLimit(config.imports.maxBytes)), trustProxy: (_addr: string, hop: number) => hop < config.trustedHops, genReqId: requestIdGenerator(config.trustedHops) }); + + // request ids: a UUIDv7 per request (or the trusted proxy's X-Request-ID, src/request-id.ts). Fastify logs it as + // reqId, Yoga reuses it (it prefers req.id) for its own logs, and every response, /health and 413s too, echoes it. + app.addHook("onRequest", async (req, reply) => void reply.header(REQUEST_ID_HEADER, req.id)); // security-kit: security headers on every response, and request.security = { ip, userAgent, … } with the // client IP taken from X-Forwarded-For only through the configured number of proxies (TRUST_PROXY). @@ -12984,6 +13040,23 @@ async function fromConfig(url: string) { ok((await sessionIp({ TRUST_PROXY: "1" })) === "203.0.113.9", "[config] TRUST_PROXY=1: the IP the load balancer saw, never the spoofed left entry"); ok((await sessionIp({})) === "127.0.0.1", "[config] no TRUST_PROXY: X-Forwarded-For is ignored"); + // request ids: a UUIDv7 per request (never Fastify's "req-1" counter); X-Request-ID kept only from a trusted proxy + { + const UUID7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + const ids = async (env: Record) => { + const c = await createContainer({ config: loadConfig({ NODE_ENV: "test", DB_BACKEND: "pg", DATABASE_URL: url, ...env }) }); + const s = await buildServer(c); + const q = async (headers: Record = {}) => + String((await s.inject({ method: "POST", url: "/graphql", headers: { "content-type": "application/json", ...headers }, payload: JSON.stringify({ query: "{ health { ok } }" }) })).headers["x-request-id"]); + const out = { a: await q(), b: await q(), sent: await q({ "x-request-id": "0123456789abcdef0123456789abcdef" }), bad: await q({ "x-request-id": "evil\tid" }), health: String((await s.inject({ url: "/health?live=1" })).headers["x-request-id"]) }; + await s.close(); + return out; + }; + const direct = await ids({}), proxied = await ids({ TRUST_PROXY: "1" }); + ok(UUID7.test(direct.a) && UUID7.test(direct.b) && direct.a !== direct.b && UUID7.test(direct.health), "[config] x-request-id: a new UUIDv7 per request, on GraphQL and /health"); + ok(UUID7.test(direct.sent) && proxied.sent === "0123456789abcdef0123456789abcdef" && UUID7.test(proxied.bad), "[config] x-request-id: the incoming one is kept only through TRUST_PROXY, and only when well-formed"); + } + // TLS fingerprint: read from TLS_FINGERPRINT_HEADER only through trusted proxies, stored hashed, a change audited const JA4 = "t13d1516h2_8daaf6152771_e5627efa2ab1", JA4b = "t13d1517h2_8daaf6152771_b1ff8ab2d16f"; { diff --git a/src/request-id.ts b/src/request-id.ts new file mode 100644 index 0000000..5df2ca4 --- /dev/null +++ b/src/request-id.ts @@ -0,0 +1,42 @@ +import { randomFillSync } from "node:crypto"; +import type { IncomingMessage } from "node:http"; + +/** The header that carries the request id, in both directions. */ +export const REQUEST_ID_HEADER = "x-request-id"; + +/** + * Ids we accept from a trusted proxy: nginx's `$request_id` (32 hex), Envoy's UUID, AWS/GCP trace ids. + * Printable, no spaces, no control characters (the id goes into every log line of the request). + */ +const INCOMING = /^[A-Za-z0-9._:\-]{8,128}$/; + +const HEX = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0")); +const bytes = new Uint8Array(16); + +/** + * A UUIDv7 (RFC 9562): 48-bit Unix time in milliseconds, then 74 random bits. Unique across replicas and + * restarts, sorts by time, and does not reveal how many requests a replica has served (Fastify's default + * was a per-process counter, "req-1", "req-2", …). + */ +export function uuidv7(now = Date.now()): string { + randomFillSync(bytes); + const ms = BigInt(now); + for (let i = 0; i < 6; i++) bytes[i] = Number((ms >> BigInt(8 * (5 - i))) & 0xffn); + bytes[6] = (bytes[6]! & 0x0f) | 0x70; // version 7 + bytes[8] = (bytes[8]! & 0x3f) | 0x80; // variant 10 + let s = ""; + for (let i = 0; i < 16; i++) s += (i === 4 || i === 6 || i === 8 || i === 10 ? "-" : "") + HEX[bytes[i]!]; + return s; +} + +/** + * Fastify's `genReqId`. With a trusted proxy in front (TRUST_PROXY ≥ 1) its X-Request-ID is kept when it + * looks like an id, so the proxy's logs and ours share one id; otherwise (no proxy, missing or malformed + * header) a new UUIDv7. Without a trusted proxy the header is client input and is ignored. + */ +export function requestIdGenerator(trustedHops: number): (req: IncomingMessage) => string { + return (req) => { + const incoming = trustedHops > 0 ? req.headers[REQUEST_ID_HEADER] : undefined; + return typeof incoming === "string" && INCOMING.test(incoming) ? incoming : uuidv7(); + }; +} diff --git a/src/server.ts b/src/server.ts index 44749e1..a7b60b4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -8,6 +8,7 @@ import { AUTH_MODE_HEADER } from "./graphql/cookies"; import { allowsLargeBody, costLimit, depthLimit, importBodyLimit } from "./graphql/limits"; import { buildSchema } from "./graphql/schema"; import { isDraining } from "./lifecycle"; +import { REQUEST_ID_HEADER, requestIdGenerator } from "./request-id"; type ServerContext = { req: FastifyRequest; reply: FastifyReply }; @@ -43,7 +44,11 @@ function rules(introspection: boolean): Plugin { */ export async function buildServer(container: Container): Promise { const { config } = container; - const app = Fastify({ logger: config.env === "production" ? { level: "info" } : config.env === "test" ? false : { level: "warn" }, bodyLimit: Math.max(config.bodyLimit, importBodyLimit(config.imports.maxBytes)), trustProxy: (_addr: string, hop: number) => hop < config.trustedHops }); + const app = Fastify({ logger: config.env === "production" ? { level: "info" } : config.env === "test" ? false : { level: "warn" }, bodyLimit: Math.max(config.bodyLimit, importBodyLimit(config.imports.maxBytes)), trustProxy: (_addr: string, hop: number) => hop < config.trustedHops, genReqId: requestIdGenerator(config.trustedHops) }); + + // request ids: a UUIDv7 per request (or the trusted proxy's X-Request-ID, src/request-id.ts). Fastify logs it as + // reqId, Yoga reuses it (it prefers req.id) for its own logs, and every response, /health and 413s too, echoes it. + app.addHook("onRequest", async (req, reply) => void reply.header(REQUEST_ID_HEADER, req.id)); // security-kit: security headers on every response, and request.security = { ip, userAgent, … } with the // client IP taken from X-Forwarded-For only through the configured number of proxies (TRUST_PROXY). diff --git a/test/run.ts b/test/run.ts index d3ee3cd..17d8a4c 100644 --- a/test/run.ts +++ b/test/run.ts @@ -459,6 +459,23 @@ async function fromConfig(url: string) { ok((await sessionIp({ TRUST_PROXY: "1" })) === "203.0.113.9", "[config] TRUST_PROXY=1: the IP the load balancer saw, never the spoofed left entry"); ok((await sessionIp({})) === "127.0.0.1", "[config] no TRUST_PROXY: X-Forwarded-For is ignored"); + // request ids: a UUIDv7 per request (never Fastify's "req-1" counter); X-Request-ID kept only from a trusted proxy + { + const UUID7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + const ids = async (env: Record) => { + const c = await createContainer({ config: loadConfig({ NODE_ENV: "test", DB_BACKEND: "pg", DATABASE_URL: url, ...env }) }); + const s = await buildServer(c); + const q = async (headers: Record = {}) => + String((await s.inject({ method: "POST", url: "/graphql", headers: { "content-type": "application/json", ...headers }, payload: JSON.stringify({ query: "{ health { ok } }" }) })).headers["x-request-id"]); + const out = { a: await q(), b: await q(), sent: await q({ "x-request-id": "0123456789abcdef0123456789abcdef" }), bad: await q({ "x-request-id": "evil\tid" }), health: String((await s.inject({ url: "/health?live=1" })).headers["x-request-id"]) }; + await s.close(); + return out; + }; + const direct = await ids({}), proxied = await ids({ TRUST_PROXY: "1" }); + ok(UUID7.test(direct.a) && UUID7.test(direct.b) && direct.a !== direct.b && UUID7.test(direct.health), "[config] x-request-id: a new UUIDv7 per request, on GraphQL and /health"); + ok(UUID7.test(direct.sent) && proxied.sent === "0123456789abcdef0123456789abcdef" && UUID7.test(proxied.bad), "[config] x-request-id: the incoming one is kept only through TRUST_PROXY, and only when well-formed"); + } + // TLS fingerprint: read from TLS_FINGERPRINT_HEADER only through trusted proxies, stored hashed, a change audited const JA4 = "t13d1516h2_8daaf6152771_e5627efa2ab1", JA4b = "t13d1517h2_8daaf6152771_b1ff8ab2d16f"; {