import { Injectable, NestMiddleware } from '@nestjs/common'; import { Request, Response, NextFunction } from 'express'; import * as crypto from 'crypto'; import { ConfigService } from '@nestjs/config'; import { ApiError } from '../errors/api-error'; import { ERROR_CODES } from '../errors/error-codes'; const UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); const COOKIE_NAME = 'csrf'; const HEADER_NAME = 'x-csrf-token'; const SKIP_PATH_PREFIXES = [ '/api/v1/auth/register-first-admin', '/api/v1/setup/create-admin', ]; function isSkipped(path: string): boolean { return SKIP_PATH_PREFIXES.some((p) => path === p || path.startsWith(p + '/')); } function readCookie(req: Request): string | null { const header = req.headers.cookie; if (!header) return null; const parts = header.split(';'); for (const part of parts) { const [k, ...v] = part.trim().split('='); if (k === COOKIE_NAME) return v.join('='); } return null; } /** * Resolve the request's effective path. In NestJS mounted middleware, * `req.path` may already be stripped to '/' when the controller is reached * through a nested router; use the original URL as the authoritative source. */ function requestPath(req: Request): string { const orig = req.originalUrl || req.url || req.path || '/'; const qIdx = orig.indexOf('?'); return qIdx >= 0 ? orig.slice(0, qIdx) : orig; } function safeEqual(a: string, b: string): boolean { const bufA = Buffer.from(a); const bufB = Buffer.from(b); if (bufA.length !== bufB.length) return false; return crypto.timingSafeEqual(bufA, bufB); } @Injectable() export class CsrfMiddleware implements NestMiddleware { constructor(private readonly config: ConfigService) {} /** * Public helper: ensure a `csrf` cookie exists on the response and return its value. * If a token already exists in the request cookies, that one is returned and re-set. * Otherwise a new one is minted, set on the response, and returned. */ setOrGetCsrfToken(req: Request, res: Response): string { const existing = readCookie(req); if (existing) { this.writeCookie(res, existing); return existing; } const token = crypto.randomBytes(24).toString('base64url'); this.writeCookie(res, token); return token; } use(req: Request, res: Response, next: NextFunction): void { const path = requestPath(req); if (isSkipped(path)) { this.setOrGetCsrfToken(req, res); return next(); } if (!UNSAFE_METHODS.has(req.method)) { this.setOrGetCsrfToken(req, res); return next(); } this.setOrGetCsrfToken(req, res); const cookieToken = readCookie(req); const headerToken = (req.headers[HEADER_NAME] as string | undefined) ?? ''; if (!cookieToken || !headerToken || !safeEqual(cookieToken, headerToken)) { throw new ApiError(ERROR_CODES.CSRF_INVALID, 'CSRF token missing or invalid', 403); } next(); } private writeCookie(res: Response, token: string): void { const tls = this.config.get('TLS_ENABLED', false); res.cookie(COOKIE_NAME, token, { httpOnly: false, sameSite: tls ? 'strict' : 'lax', secure: tls, path: '/', }); } }