import { timingSafeEqual } from 'node:crypto'; import type { NextFunction, Request, RequestHandler, Response } from 'express'; import { Logger } from 'nestjs-pino'; /** * Methods that, per RFC 7231 / 9110, MUST NOT alter server state. * The CSRF check skips them — there is nothing to forge for routes * that don't change anything. */ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); /** * Paths the CSRF middleware lets through unconditionally. These are * the OIDC entry points that *create* the session (and the token * along with it) — there is no token to verify yet. * * `GET /auth/logout` is a safe method so it's already covered by * `SAFE_METHODS`; if a future PR moves logout to POST, the bypass * here would need extending. */ const CSRF_EXEMPT_PREFIXES = ['/api/auth/login', '/api/auth/callback']; /** * Double-submit CSRF middleware (per ADR-0009 §"CSRF defense"). * * Contract: * - GET / HEAD / OPTIONS pass through. * - Unauthenticated requests pass through. CSRF protects an * authenticated identity from being abused by another origin; * there is no identity to abuse on anonymous routes. (Anonymous * POST endpoints, if they ever exist, will need a different * defense — site-key, captcha, etc.) * - `/api/auth/login` and `/api/auth/callback` are exempt: they * are the routes that *establish* the session and thereby * mint the CSRF token. * - Any other request must carry `X-CSRF-Token` whose value * equals `req.session.csrfToken`. Comparison is * `crypto.timingSafeEqual` to avoid leaking the token via * response-time differences. * - Mismatch / missing → 403 `{ error: 'csrf' }`. No further * processing. * * The middleware reads the token from the **session**, not the * cookie. The cookie is the SPA's read-only mirror; an attacker * who can plant a cookie (subdomain takeover, etc.) cannot also * plant a matching session-stored token without already being the * authenticated user. Tying the check to the server-side session * is what distinguishes "session-bound double-submit" from the * weaker pure cookie-vs-header comparison. */ export function createCsrfMiddleware(logger: Logger): RequestHandler { return function csrf(req: Request, res: Response, next: NextFunction): void { if (SAFE_METHODS.has(req.method.toUpperCase())) { next(); return; } const session = req.session; if (!session?.user) { // Anonymous mutating request — out of scope for CSRF v1. next(); return; } if (CSRF_EXEMPT_PREFIXES.some((p) => req.path.startsWith(p))) { next(); return; } const expected = session.csrfToken; const headerToken = req.get('X-CSRF-Token'); if (!expected || !headerToken || !equalsTimingSafe(headerToken, expected)) { logger.warn( { event: 'csrf.reject', path: req.path, method: req.method, hasHeaderToken: Boolean(headerToken), hasSessionToken: Boolean(expected), }, 'Csrf', ); res.status(403).json({ error: 'csrf' }); return; } next(); }; } function equalsTimingSafe(a: string, b: string): boolean { // `timingSafeEqual` requires equal-length buffers. Different // lengths short-circuit as unequal — we accept the early return // here because the length itself is not a secret. const ba = Buffer.from(a, 'utf8'); const bb = Buffer.from(b, 'utf8'); if (ba.length !== bb.length) { return false; } return timingSafeEqual(ba, bb); }