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>
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* CSRF Guard
|
||||
*
|
||||
* Implements CSRF protection using double-submit cookie pattern.
|
||||
* Validates that CSRF token in cookie matches token in header.
|
||||
* 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
|
||||
@@ -19,14 +21,23 @@ import {
|
||||
} 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) {}
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private csrfService: CsrfService
|
||||
) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
// Check if endpoint is marked to skip CSRF
|
||||
@@ -39,7 +50,7 @@ export class CsrfGuard implements CanActivate {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
||||
|
||||
// Exempt safe HTTP methods (GET, HEAD, OPTIONS)
|
||||
if (["GET", "HEAD", "OPTIONS"].includes(request.method)) {
|
||||
@@ -78,6 +89,32 @@ export class CsrfGuard implements CanActivate {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user