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,34 +1,47 @@
|
||||
/**
|
||||
* CSRF Guard Tests
|
||||
*
|
||||
* Tests CSRF protection using double-submit cookie pattern.
|
||||
* Tests CSRF protection using double-submit cookie pattern with session binding.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
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();
|
||||
guard = new CsrfGuard(reflector);
|
||||
csrfService = new CsrfService();
|
||||
csrfService.onModuleInit();
|
||||
guard = new CsrfGuard(reflector, csrfService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
const createContext = (
|
||||
method: string,
|
||||
cookies: Record<string, string> = {},
|
||||
headers: Record<string, string> = {},
|
||||
skipCsrf = false
|
||||
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 {
|
||||
@@ -41,6 +54,13 @@ describe("CsrfGuard", () => {
|
||||
} 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");
|
||||
@@ -68,73 +88,233 @@ describe("CsrfGuard", () => {
|
||||
|
||||
describe("State-changing methods requiring CSRF", () => {
|
||||
it("should reject POST without CSRF token", () => {
|
||||
const context = createContext("POST");
|
||||
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");
|
||||
const context = createContext("PUT", {}, {}, false, "user-123");
|
||||
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("should reject PATCH without CSRF token", () => {
|
||||
const context = createContext("PATCH");
|
||||
const context = createContext("PATCH", {}, {}, false, "user-123");
|
||||
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("should reject DELETE without CSRF token", () => {
|
||||
const context = createContext("DELETE");
|
||||
const context = createContext("DELETE", {}, {}, false, "user-123");
|
||||
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("should reject when only cookie token is present", () => {
|
||||
const context = createContext("POST", { "csrf-token": "abc123" });
|
||||
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 context = createContext("POST", {}, { "x-csrf-token": "abc123" });
|
||||
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": "abc123" },
|
||||
{ "x-csrf-token": "xyz789" }
|
||||
{ "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", () => {
|
||||
it("should allow when tokens match and session is valid", () => {
|
||||
const token = generateValidToken("user-123");
|
||||
const context = createContext(
|
||||
"POST",
|
||||
{ "csrf-token": "abc123" },
|
||||
{ "x-csrf-token": "abc123" }
|
||||
{ "csrf-token": token },
|
||||
{ "x-csrf-token": token },
|
||||
false,
|
||||
"user-123"
|
||||
);
|
||||
expect(guard.canActivate(context)).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow PATCH when tokens match", () => {
|
||||
it("should allow PATCH when tokens match and session is valid", () => {
|
||||
const token = generateValidToken("user-123");
|
||||
const context = createContext(
|
||||
"PATCH",
|
||||
{ "csrf-token": "token123" },
|
||||
{ "x-csrf-token": "token123" }
|
||||
{ "csrf-token": token },
|
||||
{ "x-csrf-token": token },
|
||||
false,
|
||||
"user-123"
|
||||
);
|
||||
expect(guard.canActivate(context)).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow DELETE when tokens match", () => {
|
||||
it("should allow DELETE when tokens match and session is valid", () => {
|
||||
const token = generateValidToken("user-123");
|
||||
const context = createContext(
|
||||
"DELETE",
|
||||
{ "csrf-token": "delete-token" },
|
||||
{ "x-csrf-token": "delete-token" }
|
||||
{ "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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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