/** * CSRF Guard * * Implements CSRF protection using double-submit cookie pattern with session binding. * Validates that: * 1. CSRF token in cookie matches token in header * 2. Token HMAC is valid for the current user session * * Usage: * - Apply to controllers handling state-changing operations * - Use @SkipCsrf() decorator to exempt specific endpoints * - Safe methods (GET, HEAD, OPTIONS) are automatically exempted */ import { Injectable, CanActivate, ExecutionContext, ForbiddenException, Logger, } from "@nestjs/common"; import { Reflector } from "@nestjs/core"; import { Request } from "express"; import { CsrfService } from "../services/csrf.service"; import type { AuthenticatedUser } from "../types/user.types"; export const SKIP_CSRF_KEY = "skipCsrf"; interface RequestWithUser extends Request { user?: AuthenticatedUser; } @Injectable() export class CsrfGuard implements CanActivate { private readonly logger = new Logger(CsrfGuard.name); constructor( private reflector: Reflector, private csrfService: CsrfService ) {} canActivate(context: ExecutionContext): boolean { // Check if endpoint is marked to skip CSRF const skipCsrf = this.reflector.getAllAndOverride(SKIP_CSRF_KEY, [ context.getHandler(), context.getClass(), ]); if (skipCsrf) { return true; } const request = context.switchToHttp().getRequest(); // Exempt safe HTTP methods (GET, HEAD, OPTIONS) if (["GET", "HEAD", "OPTIONS"].includes(request.method)) { return true; } // Get CSRF token from cookie and header const cookies = request.cookies as Record | undefined; const cookieToken = cookies?.["csrf-token"]; const headerToken = request.headers["x-csrf-token"] as string | undefined; // Validate tokens exist and match if (!cookieToken || !headerToken) { this.logger.warn({ event: "CSRF_TOKEN_MISSING", method: request.method, path: request.path, hasCookie: !!cookieToken, hasHeader: !!headerToken, securityEvent: true, timestamp: new Date().toISOString(), }); throw new ForbiddenException("CSRF token missing"); } if (cookieToken !== headerToken) { this.logger.warn({ event: "CSRF_TOKEN_MISMATCH", method: request.method, path: request.path, securityEvent: true, timestamp: new Date().toISOString(), }); throw new ForbiddenException("CSRF token mismatch"); } // Validate session binding via HMAC when user context is available. // CsrfGuard is a global guard (APP_GUARD) that runs before per-controller // AuthGuard, so request.user may not be populated yet. In that case, the // double-submit cookie match above is sufficient CSRF protection. const userId = request.user?.id; if (userId) { if (!this.csrfService.validateToken(cookieToken, userId)) { this.logger.warn({ event: "CSRF_SESSION_BINDING_INVALID", method: request.method, path: request.path, securityEvent: true, timestamp: new Date().toISOString(), }); throw new ForbiddenException("CSRF token not bound to session"); } } else { this.logger.debug({ event: "CSRF_SKIP_SESSION_BINDING", method: request.method, path: request.path, reason: "User context not yet available (global guard runs before AuthGuard)", }); } return true; } }