- 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>
121 lines
3.3 KiB
TypeScript
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;
|
|
}
|
|
}
|