/** * CSRF Guard Tests * * Tests CSRF protection using double-submit cookie pattern with session binding. */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { ExecutionContext, ForbiddenException } from "@nestjs/common"; import { Reflector } from "@nestjs/core"; import { CsrfGuard } from "./csrf.guard"; import { CsrfService } from "../services/csrf.service"; describe("CsrfGuard", () => { let guard: CsrfGuard; let reflector: Reflector; let csrfService: CsrfService; const originalEnv = process.env; beforeEach(() => { process.env = { ...originalEnv }; process.env.CSRF_SECRET = "test-secret-0123456789abcdef0123456789abcdef"; reflector = new Reflector(); csrfService = new CsrfService(); csrfService.onModuleInit(); guard = new CsrfGuard(reflector, csrfService); }); afterEach(() => { process.env = originalEnv; }); const createContext = ( method: string, cookies: Record = {}, headers: Record = {}, skipCsrf = false, userId?: string ): ExecutionContext => { const request = { method, cookies, headers, path: "/api/test", user: userId ? { id: userId, email: "test@example.com", name: "Test" } : undefined, }; return { switchToHttp: () => ({ getRequest: () => request, }), getHandler: () => ({}), getClass: () => ({}), getAllAndOverride: vi.fn().mockReturnValue(skipCsrf), } as unknown as ExecutionContext; }; /** * Helper to generate a valid session-bound token */ const generateValidToken = (userId: string): string => { return csrfService.generateToken(userId); }; describe("Safe HTTP methods", () => { it("should allow GET requests without CSRF token", () => { const context = createContext("GET"); expect(guard.canActivate(context)).toBe(true); }); it("should allow HEAD requests without CSRF token", () => { const context = createContext("HEAD"); expect(guard.canActivate(context)).toBe(true); }); it("should allow OPTIONS requests without CSRF token", () => { const context = createContext("OPTIONS"); expect(guard.canActivate(context)).toBe(true); }); }); describe("Endpoints marked to skip CSRF", () => { it("should allow POST requests when @SkipCsrf() is applied", () => { vi.spyOn(reflector, "getAllAndOverride").mockReturnValue(true); const context = createContext("POST"); expect(guard.canActivate(context)).toBe(true); }); }); describe("State-changing methods requiring CSRF", () => { it("should reject POST without CSRF token", () => { const context = createContext("POST", {}, {}, false, "user-123"); expect(() => guard.canActivate(context)).toThrow(ForbiddenException); expect(() => guard.canActivate(context)).toThrow("CSRF token missing"); }); it("should reject PUT without CSRF token", () => { const context = createContext("PUT", {}, {}, false, "user-123"); expect(() => guard.canActivate(context)).toThrow(ForbiddenException); }); it("should reject PATCH without CSRF token", () => { const context = createContext("PATCH", {}, {}, false, "user-123"); expect(() => guard.canActivate(context)).toThrow(ForbiddenException); }); it("should reject DELETE without CSRF token", () => { const context = createContext("DELETE", {}, {}, false, "user-123"); expect(() => guard.canActivate(context)).toThrow(ForbiddenException); }); it("should reject when only cookie token is present", () => { const token = generateValidToken("user-123"); const context = createContext("POST", { "csrf-token": token }, {}, false, "user-123"); expect(() => guard.canActivate(context)).toThrow(ForbiddenException); expect(() => guard.canActivate(context)).toThrow("CSRF token missing"); }); it("should reject when only header token is present", () => { const token = generateValidToken("user-123"); const context = createContext("POST", {}, { "x-csrf-token": token }, false, "user-123"); expect(() => guard.canActivate(context)).toThrow(ForbiddenException); expect(() => guard.canActivate(context)).toThrow("CSRF token missing"); }); it("should reject when tokens do not match", () => { const token1 = generateValidToken("user-123"); const token2 = generateValidToken("user-123"); const context = createContext( "POST", { "csrf-token": token1 }, { "x-csrf-token": token2 }, false, "user-123" ); expect(() => guard.canActivate(context)).toThrow(ForbiddenException); expect(() => guard.canActivate(context)).toThrow("CSRF token mismatch"); }); it("should allow when tokens match and session is valid", () => { const token = generateValidToken("user-123"); const context = createContext( "POST", { "csrf-token": token }, { "x-csrf-token": token }, false, "user-123" ); expect(guard.canActivate(context)).toBe(true); }); it("should allow PATCH when tokens match and session is valid", () => { const token = generateValidToken("user-123"); const context = createContext( "PATCH", { "csrf-token": token }, { "x-csrf-token": token }, false, "user-123" ); expect(guard.canActivate(context)).toBe(true); }); it("should allow DELETE when tokens match and session is valid", () => { const token = generateValidToken("user-123"); const context = createContext( "DELETE", { "csrf-token": token }, { "x-csrf-token": token }, false, "user-123" ); expect(guard.canActivate(context)).toBe(true); }); }); describe("Session binding validation", () => { it("should reject when user is not authenticated", () => { const token = generateValidToken("user-123"); const context = createContext( "POST", { "csrf-token": token }, { "x-csrf-token": token }, false // No userId - unauthenticated ); expect(() => guard.canActivate(context)).toThrow(ForbiddenException); expect(() => guard.canActivate(context)).toThrow("CSRF validation requires authentication"); }); it("should reject token from different session", () => { // Token generated for user-A const tokenForUserA = generateValidToken("user-A"); // But request is from user-B const context = createContext( "POST", { "csrf-token": tokenForUserA }, { "x-csrf-token": tokenForUserA }, false, "user-B" // Different user ); expect(() => guard.canActivate(context)).toThrow(ForbiddenException); expect(() => guard.canActivate(context)).toThrow("CSRF token not bound to session"); }); it("should reject token with invalid HMAC", () => { // Create a token with tampered HMAC const validToken = generateValidToken("user-123"); const parts = validToken.split(":"); const tamperedToken = `${parts[0]}:0000000000000000000000000000000000000000000000000000000000000000`; const context = createContext( "POST", { "csrf-token": tamperedToken }, { "x-csrf-token": tamperedToken }, false, "user-123" ); expect(() => guard.canActivate(context)).toThrow(ForbiddenException); expect(() => guard.canActivate(context)).toThrow("CSRF token not bound to session"); }); it("should reject token with invalid format", () => { const invalidToken = "not-a-valid-token"; const context = createContext( "POST", { "csrf-token": invalidToken }, { "x-csrf-token": invalidToken }, false, "user-123" ); expect(() => guard.canActivate(context)).toThrow(ForbiddenException); expect(() => guard.canActivate(context)).toThrow("CSRF token not bound to session"); }); it("should not allow token reuse across sessions", () => { // Generate token for user-A const tokenA = generateValidToken("user-A"); // Valid for user-A const contextA = createContext( "POST", { "csrf-token": tokenA }, { "x-csrf-token": tokenA }, false, "user-A" ); expect(guard.canActivate(contextA)).toBe(true); // Invalid for user-B const contextB = createContext( "POST", { "csrf-token": tokenA }, { "x-csrf-token": tokenA }, false, "user-B" ); expect(() => guard.canActivate(contextB)).toThrow("CSRF token not bound to session"); // Invalid for user-C const contextC = createContext( "POST", { "csrf-token": tokenA }, { "x-csrf-token": tokenA }, false, "user-C" ); expect(() => guard.canActivate(contextC)).toThrow("CSRF token not bound to session"); }); it("should allow each user to use only their own token", () => { const tokenA = generateValidToken("user-A"); const tokenB = generateValidToken("user-B"); // User A with token A - valid const contextAA = createContext( "POST", { "csrf-token": tokenA }, { "x-csrf-token": tokenA }, false, "user-A" ); expect(guard.canActivate(contextAA)).toBe(true); // User B with token B - valid const contextBB = createContext( "POST", { "csrf-token": tokenB }, { "x-csrf-token": tokenB }, false, "user-B" ); expect(guard.canActivate(contextBB)).toBe(true); // User A with token B - invalid (cross-session) const contextAB = createContext( "POST", { "csrf-token": tokenB }, { "x-csrf-token": tokenB }, false, "user-A" ); expect(() => guard.canActivate(contextAB)).toThrow("CSRF token not bound to session"); // User B with token A - invalid (cross-session) const contextBA = createContext( "POST", { "csrf-token": tokenA }, { "x-csrf-token": tokenA }, false, "user-B" ); expect(() => guard.canActivate(contextBA)).toThrow("CSRF token not bound to session"); }); }); });