diff --git a/apps/api/src/common/guards/csrf.guard.spec.ts b/apps/api/src/common/guards/csrf.guard.spec.ts index 6bd6c18..82c9685 100644 --- a/apps/api/src/common/guards/csrf.guard.spec.ts +++ b/apps/api/src/common/guards/csrf.guard.spec.ts @@ -174,17 +174,19 @@ describe("CsrfGuard", () => { }); describe("Session binding validation", () => { - it("should reject when user is not authenticated", () => { + it("should allow when user context is not yet available (global guard ordering)", () => { + // CsrfGuard runs as APP_GUARD before per-controller AuthGuard, + // so request.user may not be populated. Double-submit cookie match + // is sufficient protection in this case. const token = generateValidToken("user-123"); const context = createContext( "POST", { "csrf-token": token }, { "x-csrf-token": token }, false - // No userId - unauthenticated + // No userId - AuthGuard hasn't run yet ); - expect(() => guard.canActivate(context)).toThrow(ForbiddenException); - expect(() => guard.canActivate(context)).toThrow("CSRF validation requires authentication"); + expect(guard.canActivate(context)).toBe(true); }); it("should reject token from different session", () => { diff --git a/apps/api/src/common/guards/csrf.guard.ts b/apps/api/src/common/guards/csrf.guard.ts index d9f44c7..805a224 100644 --- a/apps/api/src/common/guards/csrf.guard.ts +++ b/apps/api/src/common/guards/csrf.guard.ts @@ -89,30 +89,30 @@ export class CsrfGuard implements CanActivate { throw new ForbiddenException("CSRF token mismatch"); } - // Validate session binding via HMAC + // 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) { - this.logger.warn({ - event: "CSRF_NO_USER_CONTEXT", + 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, - securityEvent: true, - timestamp: new Date().toISOString(), + reason: "User context not yet available (global guard runs before AuthGuard)", }); - - throw new ForbiddenException("CSRF validation requires authentication"); - } - - 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"); } return true;