Files
stack/apps/api/src/common/guards/csrf.guard.ts
Jason Woltje 7390cac2cc fix(#338): Bind CSRF token to user session with HMAC
- Token now includes HMAC binding to session ID
- Validates session binding on verification
- Adds CSRF_SECRET configuration requirement
- Requires authentication for CSRF token endpoint
- 51 new tests covering session binding security

Security: CSRF tokens are now cryptographically tied to user sessions,
preventing token reuse across sessions and mitigating session fixation
attacks.

Token format: {random_part}:{hmac(random_part + user_id, secret)}

Refs #338

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 16:33:22 -06:00

121 lines
3.3 KiB
TypeScript

/**
* 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<boolean>(SKIP_CSRF_KEY, [
context.getHandler(),
context.getClass(),
]);
if (skipCsrf) {
return true;
}
const request = context.switchToHttp().getRequest<RequestWithUser>();
// 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<string, string> | 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
const userId = request.user?.id;
if (!userId) {
this.logger.warn({
event: "CSRF_NO_USER_CONTEXT",
method: request.method,
path: request.path,
securityEvent: true,
timestamp: new Date().toISOString(),
});
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;
}
}