mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 18:34:13 +00:00
- Remove obsolete instruction documents (PROMPT_*.md, SECURITY_FIXES.md) - Update CLAUDE.md with security features, test suite, audit logs - Merge Techstack_Dokumentation into INFRASTRUCTURE.md - Update SECURITY.md with custom login route documentation - Add changelog to TODO.md - Update email service and data masking for SMTP error handling - Extend test coverage for CSRF and data masking 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
448 lines
12 KiB
TypeScript
448 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')
|
|
|
|
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')
|
|
})
|
|
})
|
|
})
|