cms.c2sgmbh/tests/unit/security/csrf.unit.spec.ts
Martin Porwoll 96cb6f1a47 fix(ci): improve CSRF bypass for CI and fix unit tests
- Remove NODE_ENV check from CSRF bypass (production builds need bypass too)
- Add CI environment stub to CSRF unit tests to ensure normal validation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 13:18:33 +00:00

450 lines
12 KiB
TypeScript

/**
* 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')
})
})
})