Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 13 additions & 1 deletion src/middleware/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
56 changes: 56 additions & 0 deletions src/request-id.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading