Files
stack/apps/api/src/common/controllers/csrf.controller.spec.ts
Jason Woltje ebd842f007
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
fix(#278): Implement CSRF protection using double-submit cookie pattern
Implemented comprehensive CSRF protection for all state-changing endpoints
(POST, PATCH, DELETE) using the double-submit cookie pattern.

Security Implementation:
- Created CsrfGuard using double-submit cookie validation
- Token set in httpOnly cookie and validated against X-CSRF-Token header
- Applied guard to FederationController (vulnerable endpoints)
- Safe HTTP methods (GET, HEAD, OPTIONS) automatically exempted
- Signature-based endpoints (@SkipCsrf decorator) exempted

Components Added:
- CsrfGuard: Validates cookie and header token match
- CsrfController: GET /api/v1/csrf/token endpoint for token generation
- @SkipCsrf(): Decorator to exempt endpoints with alternative auth
- Comprehensive tests (20 tests, all passing)

Protected Endpoints:
- POST /api/v1/federation/connections/initiate
- POST /api/v1/federation/connections/:id/accept
- POST /api/v1/federation/connections/:id/reject
- POST /api/v1/federation/connections/:id/disconnect
- POST /api/v1/federation/instance/regenerate-keys

Exempted Endpoints:
- POST /api/v1/federation/incoming/connect (signature-verified)
- GET requests (safe methods)

Security Features:
- httpOnly cookies prevent XSS attacks
- SameSite=strict prevents subdomain attacks
- Cryptographically secure random tokens (32 bytes)
- 24-hour token expiry
- Structured logging for security events

Testing:
- 14 guard tests covering all scenarios
- 6 controller tests for token generation
- Quality gates: lint, typecheck, build all passing

Note: Frontend integration required to use tokens. Clients must:
1. GET /api/v1/csrf/token to receive token
2. Include token in X-CSRF-Token header for state-changing requests

Fixes #278

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 20:35:00 -06:00

116 lines
2.9 KiB
TypeScript

/**
* CSRF Controller Tests
*
* Tests CSRF token generation endpoint.
*/
import { describe, it, expect, vi } from "vitest";
import { CsrfController } from "./csrf.controller";
import { Response } from "express";
describe("CsrfController", () => {
let controller: CsrfController;
controller = new CsrfController();
describe("getCsrfToken", () => {
it("should generate and return a CSRF token", () => {
const mockResponse = {
cookie: vi.fn(),
} as unknown as Response;
const result = controller.getCsrfToken(mockResponse);
expect(result).toHaveProperty("token");
expect(typeof result.token).toBe("string");
expect(result.token.length).toBe(64); // 32 bytes as hex = 64 characters
});
it("should set CSRF token in httpOnly cookie", () => {
const mockResponse = {
cookie: vi.fn(),
} as unknown as Response;
const result = controller.getCsrfToken(mockResponse);
expect(mockResponse.cookie).toHaveBeenCalledWith(
"csrf-token",
result.token,
expect.objectContaining({
httpOnly: true,
sameSite: "strict",
})
);
});
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;
controller.getCsrfToken(mockResponse);
expect(mockResponse.cookie).toHaveBeenCalledWith(
"csrf-token",
expect.any(String),
expect.objectContaining({
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;
controller.getCsrfToken(mockResponse);
expect(mockResponse.cookie).toHaveBeenCalledWith(
"csrf-token",
expect.any(String),
expect.objectContaining({
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 result1 = controller.getCsrfToken(mockResponse);
const result2 = controller.getCsrfToken(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;
controller.getCsrfToken(mockResponse);
expect(mockResponse.cookie).toHaveBeenCalledWith(
"csrf-token",
expect.any(String),
expect.objectContaining({
maxAge: 24 * 60 * 60 * 1000, // 24 hours
})
);
});
});
});