From 958bd2bed853134dded85c915e9fcec9403d754e Mon Sep 17 00:00:00 2001 From: felixvippp-ai Date: Tue, 23 Jun 2026 22:59:01 -0400 Subject: [PATCH] security: validate inbound x-request-id --- README.md | 4 +++ src/middleware/index.ts | 14 ++++++++++- src/request-id.test.ts | 56 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 src/request-id.test.ts diff --git a/README.md b/README.md index fa721c7..d381279 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,10 @@ agentpay-backend/ | `npm run dev` | Run with ts-node | | `npm start` | Run production build | +## Request IDs + +AgentPay echoes `X-Request-Id` into response headers, structured logs, and error bodies for correlation. Caller-provided IDs are accepted only when they match `^[A-Za-z0-9._-]{1,200}$`. Missing, empty, oversized, or control-character values are replaced with a fresh UUID before the value reaches a header, log line, or response body. + ## CI/CD On push/PR to `main`, GitHub Actions runs: diff --git a/src/middleware/index.ts b/src/middleware/index.ts index 53859a7..cdc6748 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -14,6 +14,8 @@ import { } from "../store/state.js"; import type { AgentPayRequest } from "../types.js"; +const SAFE_REQUEST_ID_PATTERN = /^[A-Za-z0-9._-]{1,200}$/; + /** * Installs middleware that must run before the early admin/config/metrics * routes. @@ -88,12 +90,22 @@ function securityHeadersMiddleware( /** Attaches or echoes X-Request-Id on every request. */ function requestIdMiddleware(req: Request, res: Response, next: NextFunction): void { const incoming = req.header("x-request-id"); - const id = incoming && incoming.length <= 200 ? incoming : randomUUID(); + const id = sanitizeRequestId(incoming); (req as AgentPayRequest).id = id; res.setHeader("X-Request-Id", id); next(); } +/** + * Accepts only gateway-safe request IDs for echoing into headers, logs, and + * error bodies. Invalid, empty, oversized, or control-character values are + * replaced with a fresh UUID. + */ +export function sanitizeRequestId(incoming: string | undefined): string { + if (incoming && SAFE_REQUEST_ID_PATTERN.test(incoming)) return incoming; + return randomUUID(); +} + /** Recognizes known API keys without requiring them. */ function apiKeyRecognitionMiddleware( req: Request, diff --git a/src/request-id.test.ts b/src/request-id.test.ts new file mode 100644 index 0000000..92a6293 --- /dev/null +++ b/src/request-id.test.ts @@ -0,0 +1,56 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import request from "supertest"; +import { createApp } from "./index.js"; +import { sanitizeRequestId } from "./middleware/index.js"; + +const uuidPattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +void describe("request id sanitization", () => { + void it("preserves safe gateway-provided request ids", async () => { + const app = createApp(); + const safeId = "gateway.Trace_123-abc"; + + const res = await request(app).get("/health").set("X-Request-Id", safeId); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.headers["x-request-id"], safeId); + }); + + void it("replaces CRLF and other control-character ids with UUIDs", () => { + for (const unsafeId of ["trace\r\nInjected: yes", "trace\u0000id", "trace\tid"]) { + const sanitized = sanitizeRequestId(unsafeId); + + assert.notStrictEqual(sanitized, unsafeId); + assert.match(sanitized, uuidPattern); + } + }); + + void it("replaces empty and oversized ids with UUIDs", () => { + for (const unsafeId of ["", "x".repeat(201), undefined]) { + const sanitized = sanitizeRequestId(unsafeId); + + assert.match(sanitized, uuidPattern); + } + }); + + void it("does not echo an invalid id into response headers or error bodies", async () => { + const app = createApp(); + const unsafeId = "bad\tid"; + + const res = await request(app) + .get("/api/v1/unknown-route") + .set("X-Request-Id", unsafeId); + + assert.strictEqual(res.status, 404); + assert.notStrictEqual(res.headers["x-request-id"], unsafeId); + assert.match(res.headers["x-request-id"], uuidPattern); + const bodyRequestId = res.body?.requestId as unknown; + if (typeof bodyRequestId !== "string") { + assert.fail("Expected response body to include a string requestId"); + } + assert.notStrictEqual(bodyRequestId, unsafeId); + assert.match(bodyRequestId, uuidPattern); + }); +});