Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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>
116 lines
2.9 KiB
TypeScript
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
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|