Skip to content
Merged
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
2 changes: 2 additions & 0 deletions packages/backend/src/middleware/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -119,6 +120,7 @@ export function setupMiddleware(app: Express): void {
}),
)

app.use(requestId)
app.use(requestLogger)
app.use(metricsMiddleware)
app.use(express.json())
Expand Down
36 changes: 36 additions & 0 deletions packages/backend/src/middleware/requestId.ts
Original file line number Diff line number Diff line change
@@ -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()
}
1 change: 1 addition & 0 deletions packages/backend/src/middleware/requestLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export function requestLogger(

const logParams = {
message: `${method} ${url} ${status} ${duration}ms`,
correlationId: req.requestId,
}

if (status >= 500) {
Expand Down
16 changes: 16 additions & 0 deletions packages/backend/src/types/express.d.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
}
82 changes: 82 additions & 0 deletions packages/backend/tests/unit/middleware/requestId.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> } {
const headers: Record<string, string> = {}
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')
Comment thread
LucasSantana-Dev marked this conversation as resolved.
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)
})
})
Loading