diff --git a/src/middlewares/request-logger.middleware.ts b/src/middlewares/request-logger.middleware.ts index 596130b..207a019 100644 --- a/src/middlewares/request-logger.middleware.ts +++ b/src/middlewares/request-logger.middleware.ts @@ -2,6 +2,7 @@ import { Request, Response, NextFunction } from 'express'; import { envConfig } from '../config'; import { logger } from '../utils/logger.utils'; +import { computeRequestContextHash } from '../utils/request-context-hash.utils'; /** * Lightweight request logging middleware. @@ -25,6 +26,7 @@ export const requestLoggerMiddleware = ( } const start = process.hrtime(); + const contextHash = computeRequestContextHash(req); res.on('finish', () => { const diff = process.hrtime(start); @@ -37,6 +39,7 @@ export const requestLoggerMiddleware = ( status: res.statusCode, duration: `${durationMs}ms`, requestId: req.requestId, + contextHash, }); }); diff --git a/src/utils/request-context-hash.utils.ts b/src/utils/request-context-hash.utils.ts new file mode 100644 index 0000000..3796946 --- /dev/null +++ b/src/utils/request-context-hash.utils.ts @@ -0,0 +1,38 @@ +// src/utils/request-context-hash.utils.ts +// Produces a short deterministic hash of safe (non-sensitive) request context +// fields so log entries from the same logical operation share a stable tag. +import crypto from 'crypto'; +import { Request } from 'express'; + +/** + * Compute a short SHA-256 hex digest from safe request context fields. + * + * Fields included (non-sensitive, stable per endpoint): + * - HTTP method + * - URL path without query string + * - `content-type` request header (if present) + * + * Fields deliberately excluded: + * - Authorization / Cookie headers + * - Request body + * - Query string (contains user-supplied values) + * - Full URL (includes query string) + * + * The digest is truncated to 12 hex characters (48 bits) — enough for + * trace correlation without bloating log lines. + * + * @param req - Express Request object + * @returns 12-character lowercase hex string + * + * @example + * // GET /api/creators?page=1 + * computeRequestContextHash(req) // e.g. "3a9f1c2b4e7d" + */ +export function computeRequestContextHash(req: Request): string { + const path = (req.path || '/').split('?')[0]; + const contentType = (req.headers['content-type'] ?? '').split(';')[0].trim(); + + const payload = [req.method.toUpperCase(), path, contentType].join('\x00'); + + return crypto.createHash('sha256').update(payload).digest('hex').slice(0, 12); +} diff --git a/src/utils/test/request-context-hash.utils.test.ts b/src/utils/test/request-context-hash.utils.test.ts new file mode 100644 index 0000000..99c4bdd --- /dev/null +++ b/src/utils/test/request-context-hash.utils.test.ts @@ -0,0 +1,105 @@ +import { Request } from 'express'; +import { computeRequestContextHash } from '../request-context-hash.utils'; + +function makeReq(overrides: Partial<{ method: string; path: string; headers: Record }>): Request { + return { + method: 'GET', + path: '/', + headers: {}, + ...overrides, + } as unknown as Request; +} + +describe('computeRequestContextHash()', () => { + // ── Output format ────────────────────────────────────────────────────────── + + it('returns a 12-character hex string', () => { + const hash = computeRequestContextHash(makeReq({})); + expect(hash).toMatch(/^[0-9a-f]{12}$/); + }); + + // ── Determinism ──────────────────────────────────────────────────────────── + + it('returns the same hash for identical inputs', () => { + const req = makeReq({ method: 'GET', path: '/api/creators' }); + expect(computeRequestContextHash(req)).toBe(computeRequestContextHash(req)); + }); + + it('produces a stable hash across separate calls with matching fields', () => { + const a = makeReq({ method: 'POST', path: '/api/auth/login', headers: { 'content-type': 'application/json' } }); + const b = makeReq({ method: 'POST', path: '/api/auth/login', headers: { 'content-type': 'application/json' } }); + expect(computeRequestContextHash(a)).toBe(computeRequestContextHash(b)); + }); + + // ── Sensitivity to field values ──────────────────────────────────────────── + + it('produces different hashes for different HTTP methods', () => { + const get = makeReq({ method: 'GET', path: '/api/creators' }); + const post = makeReq({ method: 'POST', path: '/api/creators' }); + expect(computeRequestContextHash(get)).not.toBe(computeRequestContextHash(post)); + }); + + it('produces different hashes for different paths', () => { + const a = makeReq({ path: '/api/creators' }); + const b = makeReq({ path: '/api/wallet' }); + expect(computeRequestContextHash(a)).not.toBe(computeRequestContextHash(b)); + }); + + it('produces different hashes for different content-type headers', () => { + const json = makeReq({ method: 'POST', path: '/upload', headers: { 'content-type': 'application/json' } }); + const form = makeReq({ method: 'POST', path: '/upload', headers: { 'content-type': 'multipart/form-data' } }); + expect(computeRequestContextHash(json)).not.toBe(computeRequestContextHash(form)); + }); + + // ── Query-string isolation ───────────────────────────────────────────────── + + it('ignores query strings in the path', () => { + const withQuery = makeReq({ path: '/api/creators?page=1&limit=20' }); + const withoutQuery = makeReq({ path: '/api/creators' }); + expect(computeRequestContextHash(withQuery)).toBe(computeRequestContextHash(withoutQuery)); + }); + + it('ignores different query strings for the same path', () => { + const a = makeReq({ path: '/api/creators?q=music' }); + const b = makeReq({ path: '/api/creators?q=sports&page=2' }); + expect(computeRequestContextHash(a)).toBe(computeRequestContextHash(b)); + }); + + // ── Sensitive-header isolation ───────────────────────────────────────────── + + it('produces the same hash regardless of Authorization header value', () => { + const noAuth = makeReq({ path: '/api/creators', headers: {} }); + const withAuth = makeReq({ path: '/api/creators', headers: { authorization: 'Bearer secret-token' } }); + expect(computeRequestContextHash(noAuth)).toBe(computeRequestContextHash(withAuth)); + }); + + it('produces the same hash regardless of Cookie header value', () => { + const noCookie = makeReq({ path: '/api/creators', headers: {} }); + const withCookie = makeReq({ path: '/api/creators', headers: { cookie: 'session=abc123' } }); + expect(computeRequestContextHash(noCookie)).toBe(computeRequestContextHash(withCookie)); + }); + + // ── Content-type charset suffix stripping ───────────────────────────────── + + it('treats content-type with and without charset as the same', () => { + const plain = makeReq({ method: 'POST', path: '/api/auth', headers: { 'content-type': 'application/json' } }); + const withCharset = makeReq({ method: 'POST', path: '/api/auth', headers: { 'content-type': 'application/json; charset=utf-8' } }); + expect(computeRequestContextHash(plain)).toBe(computeRequestContextHash(withCharset)); + }); + + // ── Missing / empty path ─────────────────────────────────────────────────── + + it('falls back to "/" when path is empty', () => { + const emptyPath = makeReq({ path: '' }); + const rootPath = makeReq({ path: '/' }); + expect(computeRequestContextHash(emptyPath)).toBe(computeRequestContextHash(rootPath)); + }); + + // ── Method normalisation ─────────────────────────────────────────────────── + + it('is case-insensitive for method (get vs GET)', () => { + const upper = makeReq({ method: 'GET', path: '/api/creators' }); + const lower = makeReq({ method: 'get', path: '/api/creators' }); + expect(computeRequestContextHash(upper)).toBe(computeRequestContextHash(lower)); + }); +});