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,37 +1,69 @@
|
||||
/**
|
||||
* CSRF Controller Tests
|
||||
*
|
||||
* Tests CSRF token generation endpoint.
|
||||
* Tests CSRF token generation endpoint with session binding.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { Request, Response } from "express";
|
||||
import { CsrfController } from "./csrf.controller";
|
||||
import { Response } from "express";
|
||||
import { CsrfService } from "../services/csrf.service";
|
||||
import type { AuthenticatedUser } from "../types/user.types";
|
||||
|
||||
interface AuthenticatedRequest extends Request {
|
||||
user?: AuthenticatedUser;
|
||||
}
|
||||
|
||||
describe("CsrfController", () => {
|
||||
let controller: CsrfController;
|
||||
let csrfService: CsrfService;
|
||||
const originalEnv = process.env;
|
||||
|
||||
controller = new CsrfController();
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
process.env.CSRF_SECRET = "test-secret-0123456789abcdef0123456789abcdef";
|
||||
csrfService = new CsrfService();
|
||||
csrfService.onModuleInit();
|
||||
controller = new CsrfController(csrfService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
const createMockRequest = (userId?: string): AuthenticatedRequest => {
|
||||
return {
|
||||
user: userId ? { id: userId, email: "test@example.com", name: "Test User" } : undefined,
|
||||
} as AuthenticatedRequest;
|
||||
};
|
||||
|
||||
const createMockResponse = (): Response => {
|
||||
return {
|
||||
cookie: vi.fn(),
|
||||
} as unknown as Response;
|
||||
};
|
||||
|
||||
describe("getCsrfToken", () => {
|
||||
it("should generate and return a CSRF token", () => {
|
||||
const mockResponse = {
|
||||
cookie: vi.fn(),
|
||||
} as unknown as Response;
|
||||
it("should generate and return a CSRF token with session binding", () => {
|
||||
const mockRequest = createMockRequest("user-123");
|
||||
const mockResponse = createMockResponse();
|
||||
|
||||
const result = controller.getCsrfToken(mockResponse);
|
||||
const result = controller.getCsrfToken(mockRequest, mockResponse);
|
||||
|
||||
expect(result).toHaveProperty("token");
|
||||
expect(typeof result.token).toBe("string");
|
||||
expect(result.token.length).toBe(64); // 32 bytes as hex = 64 characters
|
||||
// Token format: random:hmac (64 hex chars : 64 hex chars)
|
||||
expect(result.token).toContain(":");
|
||||
const parts = result.token.split(":");
|
||||
expect(parts[0]).toHaveLength(64);
|
||||
expect(parts[1]).toHaveLength(64);
|
||||
});
|
||||
|
||||
it("should set CSRF token in httpOnly cookie", () => {
|
||||
const mockResponse = {
|
||||
cookie: vi.fn(),
|
||||
} as unknown as Response;
|
||||
const mockRequest = createMockRequest("user-123");
|
||||
const mockResponse = createMockResponse();
|
||||
|
||||
const result = controller.getCsrfToken(mockResponse);
|
||||
const result = controller.getCsrfToken(mockRequest, mockResponse);
|
||||
|
||||
expect(mockResponse.cookie).toHaveBeenCalledWith(
|
||||
"csrf-token",
|
||||
@@ -44,14 +76,12 @@ describe("CsrfController", () => {
|
||||
});
|
||||
|
||||
it("should set secure flag in production", () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = "production";
|
||||
|
||||
const mockResponse = {
|
||||
cookie: vi.fn(),
|
||||
} as unknown as Response;
|
||||
const mockRequest = createMockRequest("user-123");
|
||||
const mockResponse = createMockResponse();
|
||||
|
||||
controller.getCsrfToken(mockResponse);
|
||||
controller.getCsrfToken(mockRequest, mockResponse);
|
||||
|
||||
expect(mockResponse.cookie).toHaveBeenCalledWith(
|
||||
"csrf-token",
|
||||
@@ -60,19 +90,15 @@ describe("CsrfController", () => {
|
||||
secure: true,
|
||||
})
|
||||
);
|
||||
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
});
|
||||
|
||||
it("should not set secure flag in development", () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = "development";
|
||||
|
||||
const mockResponse = {
|
||||
cookie: vi.fn(),
|
||||
} as unknown as Response;
|
||||
const mockRequest = createMockRequest("user-123");
|
||||
const mockResponse = createMockResponse();
|
||||
|
||||
controller.getCsrfToken(mockResponse);
|
||||
controller.getCsrfToken(mockRequest, mockResponse);
|
||||
|
||||
expect(mockResponse.cookie).toHaveBeenCalledWith(
|
||||
"csrf-token",
|
||||
@@ -81,27 +107,23 @@ describe("CsrfController", () => {
|
||||
secure: false,
|
||||
})
|
||||
);
|
||||
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
});
|
||||
|
||||
it("should generate unique tokens on each call", () => {
|
||||
const mockResponse = {
|
||||
cookie: vi.fn(),
|
||||
} as unknown as Response;
|
||||
const mockRequest = createMockRequest("user-123");
|
||||
const mockResponse = createMockResponse();
|
||||
|
||||
const result1 = controller.getCsrfToken(mockResponse);
|
||||
const result2 = controller.getCsrfToken(mockResponse);
|
||||
const result1 = controller.getCsrfToken(mockRequest, mockResponse);
|
||||
const result2 = controller.getCsrfToken(mockRequest, mockResponse);
|
||||
|
||||
expect(result1.token).not.toBe(result2.token);
|
||||
});
|
||||
|
||||
it("should set cookie with 24 hour expiry", () => {
|
||||
const mockResponse = {
|
||||
cookie: vi.fn(),
|
||||
} as unknown as Response;
|
||||
const mockRequest = createMockRequest("user-123");
|
||||
const mockResponse = createMockResponse();
|
||||
|
||||
controller.getCsrfToken(mockResponse);
|
||||
controller.getCsrfToken(mockRequest, mockResponse);
|
||||
|
||||
expect(mockResponse.cookie).toHaveBeenCalledWith(
|
||||
"csrf-token",
|
||||
@@ -111,5 +133,45 @@ describe("CsrfController", () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error when user is not authenticated", () => {
|
||||
const mockRequest = createMockRequest(); // No user ID
|
||||
const mockResponse = createMockResponse();
|
||||
|
||||
expect(() => controller.getCsrfToken(mockRequest, mockResponse)).toThrow(
|
||||
"User ID not available after authentication"
|
||||
);
|
||||
});
|
||||
|
||||
it("should generate token bound to specific user session", () => {
|
||||
const mockRequest = createMockRequest("user-123");
|
||||
const mockResponse = createMockResponse();
|
||||
|
||||
const result = controller.getCsrfToken(mockRequest, mockResponse);
|
||||
|
||||
// Token should be valid for user-123
|
||||
expect(csrfService.validateToken(result.token, "user-123")).toBe(true);
|
||||
|
||||
// Token should be invalid for different user
|
||||
expect(csrfService.validateToken(result.token, "user-456")).toBe(false);
|
||||
});
|
||||
|
||||
it("should generate different tokens for different users", () => {
|
||||
const mockResponse = createMockResponse();
|
||||
|
||||
const request1 = createMockRequest("user-A");
|
||||
const request2 = createMockRequest("user-B");
|
||||
|
||||
const result1 = controller.getCsrfToken(request1, mockResponse);
|
||||
const result2 = controller.getCsrfToken(request2, mockResponse);
|
||||
|
||||
expect(result1.token).not.toBe(result2.token);
|
||||
|
||||
// Each token only valid for its user
|
||||
expect(csrfService.validateToken(result1.token, "user-A")).toBe(true);
|
||||
expect(csrfService.validateToken(result1.token, "user-B")).toBe(false);
|
||||
expect(csrfService.validateToken(result2.token, "user-B")).toBe(true);
|
||||
expect(csrfService.validateToken(result2.token, "user-A")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,24 +2,46 @@
|
||||
* CSRF Controller
|
||||
*
|
||||
* Provides CSRF token generation endpoint for client applications.
|
||||
* Tokens are cryptographically bound to the user session via HMAC.
|
||||
*/
|
||||
|
||||
import { Controller, Get, Res } from "@nestjs/common";
|
||||
import { Response } from "express";
|
||||
import * as crypto from "crypto";
|
||||
import { Controller, Get, Res, Req, UseGuards } from "@nestjs/common";
|
||||
import { Response, Request } from "express";
|
||||
import { SkipCsrf } from "../decorators/skip-csrf.decorator";
|
||||
import { CsrfService } from "../services/csrf.service";
|
||||
import { AuthGuard } from "../../auth/guards/auth.guard";
|
||||
import type { AuthenticatedUser } from "../types/user.types";
|
||||
|
||||
interface AuthenticatedRequest extends Request {
|
||||
user?: AuthenticatedUser;
|
||||
}
|
||||
|
||||
@Controller("api/v1/csrf")
|
||||
export class CsrfController {
|
||||
constructor(private readonly csrfService: CsrfService) {}
|
||||
|
||||
/**
|
||||
* Generate and set CSRF token
|
||||
* Generate and set CSRF token bound to user session
|
||||
* Requires authentication to bind token to session
|
||||
* Returns token to client and sets it in httpOnly cookie
|
||||
*/
|
||||
@Get("token")
|
||||
@UseGuards(AuthGuard)
|
||||
@SkipCsrf() // This endpoint itself doesn't need CSRF protection
|
||||
getCsrfToken(@Res({ passthrough: true }) response: Response): { token: string } {
|
||||
// Generate cryptographically secure random token
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
getCsrfToken(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Res({ passthrough: true }) response: Response
|
||||
): { token: string } {
|
||||
// Get user ID from authenticated request
|
||||
const userId = request.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
// This should not happen if AuthGuard is working correctly
|
||||
throw new Error("User ID not available after authentication");
|
||||
}
|
||||
|
||||
// Generate session-bound CSRF token
|
||||
const token = this.csrfService.generateToken(userId);
|
||||
|
||||
// Set token in httpOnly cookie
|
||||
response.cookie("csrf-token", token, {
|
||||
|
||||
Reference in New Issue
Block a user