/** * CSRF Protection Unit Tests * * Tests for the CSRF protection module. * Covers token generation/validation, origin checking, and double-submit pattern. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { NextRequest } from 'next/server' // Mock environment variables before importing vi.stubEnv('CSRF_SECRET', 'test-csrf-secret-key-12345') vi.stubEnv('PAYLOAD_PUBLIC_SERVER_URL', 'https://test.example.com') vi.stubEnv('NEXT_PUBLIC_SERVER_URL', 'https://test.example.com') // Clear CI environment variable to ensure CSRF validation works normally during tests vi.stubEnv('CI', '') import { generateCsrfToken, validateCsrfToken, validateOrigin, validateCsrf, CSRF_HEADER_NAME, CSRF_COOKIE, } from '@/lib/security/csrf' describe('CSRF Protection', () => { beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(new Date('2024-01-15T12:00:00.000Z')) }) afterEach(() => { vi.useRealTimers() }) describe('generateCsrfToken', () => { it('generates a token with correct format', () => { const token = generateCsrfToken() // Token format: timestamp:random:signature const parts = token.split(':') expect(parts.length).toBe(3) }) it('generates unique tokens', () => { const token1 = generateCsrfToken() const token2 = generateCsrfToken() expect(token1).not.toBe(token2) }) it('includes timestamp in base36', () => { const token = generateCsrfToken() const [timestampPart] = token.split(':') // Should be parseable as base36 const timestamp = parseInt(timestampPart, 36) expect(timestamp).toBeGreaterThan(0) }) it('generates consistent signature for same input', () => { // Since random part is different, signatures will differ // This test verifies the token is well-formed const token = generateCsrfToken() const [, , signature] = token.split(':') expect(signature.length).toBe(16) // HMAC hex truncated to 16 chars }) }) describe('validateCsrfToken', () => { it('validates a valid token', () => { const token = generateCsrfToken() const result = validateCsrfToken(token) expect(result.valid).toBe(true) }) it('rejects empty token', () => { const result = validateCsrfToken('') expect(result.valid).toBe(false) expect(result.reason).toBe('No token provided') }) it('rejects malformed token (wrong parts)', () => { const result = validateCsrfToken('only-one-part') expect(result.valid).toBe(false) expect(result.reason).toBe('Invalid token format') }) it('rejects token with invalid signature', () => { const token = generateCsrfToken() const parts = token.split(':') const tamperedToken = `${parts[0]}:${parts[1]}:invalidsignature` const result = validateCsrfToken(tamperedToken) expect(result.valid).toBe(false) expect(result.reason).toBe('Invalid signature') }) it('rejects expired token', () => { const token = generateCsrfToken() // Advance time by 2 hours (past 1-hour expiry) vi.advanceTimersByTime(2 * 60 * 60 * 1000) const result = validateCsrfToken(token) expect(result.valid).toBe(false) expect(result.reason).toBe('Token expired') }) it('accepts token within expiry window', () => { const token = generateCsrfToken() // Advance time by 30 minutes (within 1-hour expiry) vi.advanceTimersByTime(30 * 60 * 1000) const result = validateCsrfToken(token) expect(result.valid).toBe(true) }) it('rejects token with invalid timestamp', () => { const result = validateCsrfToken('invalid:random:signature') expect(result.valid).toBe(false) }) }) describe('validateOrigin', () => { it('allows requests without origin (server-to-server)', () => { const result = validateOrigin(null) expect(result.valid).toBe(true) }) it('allows configured server URL', () => { const result = validateOrigin('https://test.example.com') expect(result.valid).toBe(true) }) it('allows localhost for development', () => { const result1 = validateOrigin('http://localhost:3000') const result2 = validateOrigin('http://localhost:3001') expect(result1.valid).toBe(true) expect(result2.valid).toBe(true) }) it('allows production domains', () => { const productionDomains = [ 'https://pl.c2sgmbh.de', 'https://porwoll.de', 'https://complexcaresolutions.de', 'https://gunshin.de', ] productionDomains.forEach((origin) => { const result = validateOrigin(origin) expect(result.valid).toBe(true) }) }) it('allows subdomains of production domains', () => { const result = validateOrigin('https://www.porwoll.de') expect(result.valid).toBe(true) }) it('rejects unknown origins', () => { const result = validateOrigin('https://malicious-site.com') expect(result.valid).toBe(false) expect(result.reason).toContain('not allowed') }) it('rejects HTTP for production domains', () => { const result = validateOrigin('http://porwoll.de') expect(result.valid).toBe(false) }) }) describe('validateCsrf', () => { function createMockRequest( method: string, options: { origin?: string | null csrfHeader?: string csrfCookie?: string authorization?: string contentType?: string referer?: string } = {}, ): NextRequest { const headers = new Headers() if (options.origin !== null) { headers.set('origin', options.origin || 'https://test.example.com') } if (options.csrfHeader) { headers.set(CSRF_HEADER_NAME, options.csrfHeader) } if (options.authorization) { headers.set('authorization', options.authorization) } if (options.contentType) { headers.set('content-type', options.contentType) } if (options.referer) { headers.set('referer', options.referer) } const url = 'https://test.example.com/api/test' const request = new NextRequest(url, { method, headers, }) // Mock cookies if (options.csrfCookie) { Object.defineProperty(request, 'cookies', { value: { get: (name: string) => name === CSRF_COOKIE ? { value: options.csrfCookie } : undefined, }, }) } else { Object.defineProperty(request, 'cookies', { value: { get: () => undefined, }, }) } return request } describe('Safe Methods', () => { it('allows GET requests without CSRF token', () => { const req = createMockRequest('GET') const result = validateCsrf(req) expect(result.valid).toBe(true) }) it('allows HEAD requests without CSRF token', () => { const req = createMockRequest('HEAD') const result = validateCsrf(req) expect(result.valid).toBe(true) }) it('allows OPTIONS requests without CSRF token', () => { const req = createMockRequest('OPTIONS') const result = validateCsrf(req) expect(result.valid).toBe(true) }) }) describe('Server-to-Server Requests', () => { it('allows JSON requests with Authorization header but no Origin', () => { const req = createMockRequest('POST', { origin: null, authorization: 'Bearer some-token', contentType: 'application/json', }) const result = validateCsrf(req) expect(result.valid).toBe(true) }) it('requires CSRF for requests with Origin even if authenticated', () => { const req = createMockRequest('POST', { origin: 'https://test.example.com', authorization: 'Bearer some-token', contentType: 'application/json', }) const result = validateCsrf(req) expect(result.valid).toBe(false) expect(result.reason).toContain('CSRF token missing') }) }) describe('Admin Panel Requests', () => { it('allows requests from /admin without CSRF token', () => { const req = createMockRequest('POST', { referer: 'https://test.example.com/admin/login', }) const result = validateCsrf(req) expect(result.valid).toBe(true) }) it('allows requests from /admin subpaths without CSRF token', () => { const req = createMockRequest('POST', { referer: 'https://test.example.com/admin/collections/users', }) const result = validateCsrf(req) expect(result.valid).toBe(true) }) it('requires CSRF for non-admin referers', () => { const req = createMockRequest('POST', { referer: 'https://test.example.com/some-other-page', }) const result = validateCsrf(req) expect(result.valid).toBe(false) expect(result.reason).toContain('CSRF token missing') }) }) describe('Double Submit Cookie Pattern', () => { it('validates matching header and cookie tokens', () => { const token = generateCsrfToken() const req = createMockRequest('POST', { csrfHeader: token, csrfCookie: token, }) const result = validateCsrf(req) expect(result.valid).toBe(true) }) it('rejects missing header token', () => { const token = generateCsrfToken() const req = createMockRequest('POST', { csrfCookie: token, }) const result = validateCsrf(req) expect(result.valid).toBe(false) expect(result.reason).toBe('CSRF token missing') }) it('rejects missing cookie token', () => { const token = generateCsrfToken() const req = createMockRequest('POST', { csrfHeader: token, }) const result = validateCsrf(req) expect(result.valid).toBe(false) expect(result.reason).toBe('CSRF token missing') }) it('rejects mismatched tokens', () => { const token1 = generateCsrfToken() const token2 = generateCsrfToken() const req = createMockRequest('POST', { csrfHeader: token1, csrfCookie: token2, }) const result = validateCsrf(req) expect(result.valid).toBe(false) expect(result.reason).toBe('CSRF token mismatch') }) it('validates token content after matching', () => { const invalidToken = 'invalid:token:format' const req = createMockRequest('POST', { csrfHeader: invalidToken, csrfCookie: invalidToken, }) const result = validateCsrf(req) expect(result.valid).toBe(false) }) }) describe('Origin Validation', () => { it('rejects requests from disallowed origins', () => { const token = generateCsrfToken() const req = createMockRequest('POST', { origin: 'https://evil-site.com', csrfHeader: token, csrfCookie: token, }) const result = validateCsrf(req) expect(result.valid).toBe(false) expect(result.reason).toContain('not allowed') }) }) describe('PUT and DELETE Methods', () => { it('requires CSRF for PUT requests', () => { const req = createMockRequest('PUT', {}) const result = validateCsrf(req) expect(result.valid).toBe(false) }) it('requires CSRF for DELETE requests', () => { const req = createMockRequest('DELETE', {}) const result = validateCsrf(req) expect(result.valid).toBe(false) }) it('validates CSRF for PUT with valid tokens', () => { const token = generateCsrfToken() const req = createMockRequest('PUT', { csrfHeader: token, csrfCookie: token, }) const result = validateCsrf(req) expect(result.valid).toBe(true) }) }) }) describe('Constants', () => { it('exports correct header name', () => { expect(CSRF_HEADER_NAME).toBe('X-CSRF-Token') }) it('exports correct cookie name', () => { expect(CSRF_COOKIE).toBe('csrf-token') }) }) })