diff --git a/packages/backend/src/middleware/index.ts b/packages/backend/src/middleware/index.ts index 0ed7ac280..cfb903631 100644 --- a/packages/backend/src/middleware/index.ts +++ b/packages/backend/src/middleware/index.ts @@ -5,6 +5,7 @@ import helmet from 'helmet' import { existsSync } from 'node:fs' import path from 'path' import { setupSessionMiddleware } from './session' +import { requestId } from './requestId' import { requestLogger } from './requestLogger' import { metricsMiddleware } from './metrics' import { getFrontendOrigins } from '../utils/frontendOrigin' @@ -119,6 +120,7 @@ export function setupMiddleware(app: Express): void { }), ) + app.use(requestId) app.use(requestLogger) app.use(metricsMiddleware) app.use(express.json()) diff --git a/packages/backend/src/middleware/requestId.ts b/packages/backend/src/middleware/requestId.ts new file mode 100644 index 000000000..cf5d8b8d8 --- /dev/null +++ b/packages/backend/src/middleware/requestId.ts @@ -0,0 +1,36 @@ +import type { Request, Response, NextFunction } from 'express' +import { mintCorrelationId } from '@lucky/shared/utils/support' + +const REQUEST_ID_HEADER = 'x-request-id' +const RESPONSE_HEADER = 'X-Request-Id' + +// An inbound id is attacker-controllable and gets logged, so it is only +// honoured when it matches the same safe, bounded charset `mintCorrelationId` +// produces ([A-Za-z0-9_-], 1-64 chars). Anything else (newlines for log +// injection, oversized values) is dropped and a fresh id is minted. The +// pattern is anchored with a single bounded quantifier — no ReDoS surface. +const SAFE_INBOUND_ID = /^[A-Za-z0-9_-]{1,64}$/ + +/** + * Assigns a correlation id to every request: honours a valid inbound + * `X-Request-Id` (so an upstream proxy / client trace id is preserved across + * the Cloudflare tunnel + nginx hop), otherwise mints a fresh one. Exposes it + * on `req.requestId` and echoes it back as the `X-Request-Id` response header + * for client-side correlation. + * + * Must be registered BEFORE `requestLogger` so the id is available to the + * request-summary log line. + */ +export function requestId( + req: Request, + res: Response, + next: NextFunction, +): void { + const inbound = req.get(REQUEST_ID_HEADER) + const id = + inbound && SAFE_INBOUND_ID.test(inbound) ? inbound : mintCorrelationId() + + req.requestId = id + res.setHeader(RESPONSE_HEADER, id) + next() +} diff --git a/packages/backend/src/middleware/requestLogger.ts b/packages/backend/src/middleware/requestLogger.ts index 96198aa7e..02964488f 100644 --- a/packages/backend/src/middleware/requestLogger.ts +++ b/packages/backend/src/middleware/requestLogger.ts @@ -16,6 +16,7 @@ export function requestLogger( const logParams = { message: `${method} ${url} ${status} ${duration}ms`, + correlationId: req.requestId, } if (status >= 500) { diff --git a/packages/backend/src/types/express.d.ts b/packages/backend/src/types/express.d.ts new file mode 100644 index 000000000..e675fabaa --- /dev/null +++ b/packages/backend/src/types/express.d.ts @@ -0,0 +1,16 @@ +import 'express' + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Request { + /** + * Per-request correlation id, assigned by the `requestId` + * middleware (honours a sane inbound `X-Request-Id`, else minted). + * Threaded into request logs so a single request can be traced + * across log lines. See decisions/2026-06-01-logging-observability-hardening.md (Track C7). + */ + requestId?: string + } + } +} diff --git a/packages/backend/tests/unit/middleware/requestId.test.ts b/packages/backend/tests/unit/middleware/requestId.test.ts new file mode 100644 index 000000000..7e524251a --- /dev/null +++ b/packages/backend/tests/unit/middleware/requestId.test.ts @@ -0,0 +1,82 @@ +import { describe, test, expect, jest } from '@jest/globals' +import type { Request, Response } from 'express' +import { requestId } from '../../../src/middleware/requestId' + +function createReq(headerValue?: string): Request { + return { + get: (name: string) => + name.toLowerCase() === 'x-request-id' ? headerValue : undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any +} + +function createRes(): Response & { headers: Record } { + const headers: Record = {} + return { + headers, + setHeader: (name: string, value: string) => { + headers[name] = value + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any +} + +describe('requestId middleware', () => { + test('mints an id when no inbound header is present', () => { + const req = createReq() + const res = createRes() + const next = jest.fn() + + requestId(req, res, next) + + expect(req.requestId).toMatch(/^[A-Za-z0-9_-]{8}$/) + expect(res.headers['X-Request-Id']).toBe(req.requestId) + expect(next).toHaveBeenCalledTimes(1) + }) + + test('honours a valid inbound X-Request-Id', () => { + const req = createReq('abc-DEF_123') + const res = createRes() + const next = jest.fn() + + requestId(req, res, next) + + expect(req.requestId).toBe('abc-DEF_123') + expect(res.headers['X-Request-Id']).toBe('abc-DEF_123') + }) + + test('mints a fresh id when the inbound header has unsafe characters', () => { + // A newline would let an attacker inject forged log lines. + const req = createReq('evil\ninjected') + const res = createRes() + const next = jest.fn() + + requestId(req, res, next) + + expect(req.requestId).not.toBe('evil\ninjected') + expect(req.requestId).toMatch(/^[A-Za-z0-9_-]{8}$/) + expect(res.headers['X-Request-Id']).toBe(req.requestId) + }) + + test('mints a fresh id when the inbound header is over 64 chars', () => { + const req = createReq('a'.repeat(65)) + const res = createRes() + const next = jest.fn() + + requestId(req, res, next) + + expect(req.requestId).toMatch(/^[A-Za-z0-9_-]{8}$/) + expect(res.headers['X-Request-Id']).toBe(req.requestId) + }) + + test('mints a fresh id when the inbound header is empty', () => { + const req = createReq('') + const res = createRes() + const next = jest.fn() + + requestId(req, res, next) + + expect(req.requestId).toMatch(/^[A-Za-z0-9_-]{8}$/) + expect(res.headers['X-Request-Id']).toBe(req.requestId) + }) +})