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
3 changes: 3 additions & 0 deletions src/middlewares/request-logger.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -25,6 +26,7 @@ export const requestLoggerMiddleware = (
}

const start = process.hrtime();
const contextHash = computeRequestContextHash(req);

res.on('finish', () => {
const diff = process.hrtime(start);
Expand All @@ -37,6 +39,7 @@ export const requestLoggerMiddleware = (
status: res.statusCode,
duration: `${durationMs}ms`,
requestId: req.requestId,
contextHash,
});
});

Expand Down
38 changes: 38 additions & 0 deletions src/utils/request-context-hash.utils.ts
Original file line number Diff line number Diff line change
@@ -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();
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

req.headers['content-type'] is typed as string | string[] | undefined (IncomingHttpHeaders). Calling .split() on it will either fail TypeScript compilation or throw at runtime if multiple content-type headers are present. Normalize to a single string first (e.g., Array.isArray(v) ? v[0] : v ?? '') before splitting/trim.

Suggested change
const contentType = (req.headers['content-type'] ?? '').split(';')[0].trim();
const rawContentType = req.headers['content-type'];
const normalizedContentType = Array.isArray(rawContentType)
? (rawContentType[0] ?? '')
: (rawContentType ?? '');
const contentType = normalizedContentType.split(';')[0].trim();

Copilot uses AI. Check for mistakes.

const payload = [req.method.toUpperCase(), path, contentType].join('\x00');

return crypto.createHash('sha256').update(payload).digest('hex').slice(0, 12);
}
105 changes: 105 additions & 0 deletions src/utils/test/request-context-hash.utils.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> }>): Request {
return {
method: 'GET',
path: '/',
headers: {},
...overrides,
} as unknown as Request;
Comment on lines +4 to +10
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The helper should handle content-type being a string[] (possible when duplicate headers are present), but the tests only cover the string case and the makeReq helper currently types headers as Record<string, string>, which makes it hard to add this coverage. Consider widening the header type (e.g., Record<string, string | string[]>) and adding a test asserting ['application/json; charset=utf-8'] behaves the same as 'application/json; charset=utf-8'.

Copilot uses AI. Check for mistakes.
}

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));
});
});
Loading