mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 17:24:12 +00:00
Add 143 security tests covering all security modules: Unit Tests (125 tests): - rate-limiter.unit.spec.ts: limiter creation, request tracking, blocking, window reset, IP extraction, header generation - csrf.unit.spec.ts: token generation/validation, origin checking, double submit cookie pattern, referer validation - ip-allowlist.unit.spec.ts: CIDR matching, wildcards, endpoint- specific allowlist/blocklist rules, IP extraction - data-masking.unit.spec.ts: field detection, pattern matching, recursive masking, JWT/connection string/private key handling API Integration Tests (18 tests): - security-api.int.spec.ts: rate limiting responses, IP blocking, CSRF protection on state-changing endpoints Test Infrastructure: - tests/helpers/security-test-utils.ts: CSRF token generators, mock request builders, environment setup utilities - vitest.config.mts: updated to include unit tests - package.json: added test:unit and test:security scripts - .github/workflows/security.yml: added security-tests CI job Also updated detect-secrets.sh to ignore .spec.ts and .test.ts files which may contain example secrets for testing purposes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
518 lines
15 KiB
TypeScript
518 lines
15 KiB
TypeScript
/**
|
|
* Security API Integration Tests
|
|
*
|
|
* Tests for security enforcement on API endpoints.
|
|
* Covers rate limiting, CSRF protection, and IP blocking.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
import { NextRequest, NextResponse } from 'next/server'
|
|
import {
|
|
generateTestCsrfToken,
|
|
generateExpiredCsrfToken,
|
|
generateInvalidCsrfToken,
|
|
createMockRequest,
|
|
randomIp,
|
|
uniqueTestId,
|
|
} from '../helpers/security-test-utils'
|
|
|
|
// ============================================================================
|
|
// Mock Setup
|
|
// ============================================================================
|
|
|
|
// Mock Redis (not available in test environment)
|
|
vi.mock('ioredis', () => ({
|
|
default: vi.fn().mockImplementation(() => ({
|
|
incr: vi.fn().mockResolvedValue(1),
|
|
pexpire: vi.fn().mockResolvedValue(1),
|
|
pttl: vi.fn().mockResolvedValue(60000),
|
|
keys: vi.fn().mockResolvedValue([]),
|
|
del: vi.fn().mockResolvedValue(0),
|
|
quit: vi.fn(),
|
|
on: vi.fn(),
|
|
})),
|
|
}))
|
|
|
|
vi.mock('@/lib/redis', () => ({
|
|
getRedisClient: vi.fn(() => null),
|
|
isRedisAvailable: vi.fn(() => false),
|
|
}))
|
|
|
|
// Mock Payload for endpoints that need it
|
|
vi.mock('payload', () => ({
|
|
getPayload: vi.fn().mockResolvedValue({
|
|
auth: vi.fn().mockResolvedValue({ user: null }),
|
|
find: vi.fn().mockResolvedValue({ docs: [], totalDocs: 0, page: 1, totalPages: 0 }),
|
|
create: vi.fn().mockResolvedValue({ id: 1 }),
|
|
login: vi.fn().mockRejectedValue(new Error('Invalid credentials')),
|
|
}),
|
|
}))
|
|
|
|
// Mock config
|
|
vi.mock('@payload-config', () => ({
|
|
default: Promise.resolve({}),
|
|
}))
|
|
|
|
describe('Security API Integration', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
vi.useFakeTimers()
|
|
|
|
// Set up environment
|
|
vi.stubEnv('CSRF_SECRET', 'test-csrf-secret-for-testing')
|
|
vi.stubEnv('PAYLOAD_PUBLIC_SERVER_URL', 'https://test.example.com')
|
|
vi.stubEnv('NEXT_PUBLIC_SERVER_URL', 'https://test.example.com')
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
vi.unstubAllEnvs()
|
|
})
|
|
|
|
describe('Rate Limiting', () => {
|
|
describe('searchLimiter on /api/posts', () => {
|
|
it('includes rate limit headers on success', async () => {
|
|
// Reset modules to get fresh rate limiter state
|
|
vi.resetModules()
|
|
const { GET } = await import('@/app/(frontend)/api/posts/route')
|
|
|
|
const testIp = uniqueTestId('ip')
|
|
const req = createMockRequest({
|
|
method: 'GET',
|
|
url: 'https://test.example.com/api/posts',
|
|
ip: testIp,
|
|
})
|
|
|
|
const response = await GET(req)
|
|
|
|
expect(response.headers.get('X-RateLimit-Limit')).toBe('30')
|
|
expect(response.headers.get('X-RateLimit-Remaining')).toBeDefined()
|
|
expect(response.headers.get('X-RateLimit-Reset')).toBeDefined()
|
|
})
|
|
|
|
it('returns 429 when rate limit exceeded', async () => {
|
|
vi.resetModules()
|
|
const { GET } = await import('@/app/(frontend)/api/posts/route')
|
|
|
|
const testIp = uniqueTestId('blocked-ip')
|
|
const req = () =>
|
|
createMockRequest({
|
|
method: 'GET',
|
|
url: 'https://test.example.com/api/posts',
|
|
ip: testIp,
|
|
})
|
|
|
|
// Exhaust rate limit (30 requests)
|
|
for (let i = 0; i < 30; i++) {
|
|
await GET(req())
|
|
}
|
|
|
|
// 31st request should be blocked
|
|
const response = await GET(req())
|
|
|
|
expect(response.status).toBe(429)
|
|
expect(response.headers.get('Retry-After')).toBeDefined()
|
|
})
|
|
})
|
|
|
|
describe('searchLimiter on /api/search', () => {
|
|
it('includes rate limit headers', async () => {
|
|
vi.resetModules()
|
|
const { GET } = await import('@/app/(frontend)/api/search/route')
|
|
|
|
const testIp = uniqueTestId('search-ip')
|
|
const req = createMockRequest({
|
|
method: 'GET',
|
|
url: 'https://test.example.com/api/search?q=test',
|
|
ip: testIp,
|
|
})
|
|
|
|
const response = await GET(req)
|
|
|
|
expect(response.headers.has('X-RateLimit-Remaining')).toBe(true)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('IP Blocking', () => {
|
|
it('blocks requests from blocked IPs on /api/posts', async () => {
|
|
vi.stubEnv('BLOCKED_IPS', '192.168.100.50')
|
|
vi.resetModules()
|
|
const { GET } = await import('@/app/(frontend)/api/posts/route')
|
|
|
|
const req = createMockRequest({
|
|
method: 'GET',
|
|
url: 'https://test.example.com/api/posts',
|
|
ip: '192.168.100.50',
|
|
})
|
|
|
|
const response = await GET(req)
|
|
|
|
expect(response.status).toBe(403)
|
|
const body = await response.json()
|
|
expect(body.error).toContain('Access denied')
|
|
})
|
|
|
|
it('blocks requests from blocked CIDR range', async () => {
|
|
vi.stubEnv('BLOCKED_IPS', '10.0.0.0/8')
|
|
vi.resetModules()
|
|
const { GET } = await import('@/app/(frontend)/api/posts/route')
|
|
|
|
const req = createMockRequest({
|
|
method: 'GET',
|
|
url: 'https://test.example.com/api/posts',
|
|
ip: '10.50.100.200',
|
|
})
|
|
|
|
const response = await GET(req)
|
|
|
|
expect(response.status).toBe(403)
|
|
})
|
|
|
|
it('allows requests from non-blocked IPs', async () => {
|
|
vi.stubEnv('BLOCKED_IPS', '192.168.100.50')
|
|
vi.resetModules()
|
|
const { GET } = await import('@/app/(frontend)/api/posts/route')
|
|
|
|
const req = createMockRequest({
|
|
method: 'GET',
|
|
url: 'https://test.example.com/api/posts',
|
|
ip: '8.8.8.8',
|
|
})
|
|
|
|
const response = await GET(req)
|
|
|
|
expect(response.status).not.toBe(403)
|
|
})
|
|
})
|
|
|
|
describe('CSRF Protection', () => {
|
|
describe('Login Endpoints', () => {
|
|
it('validates CSRF on /api/auth/login', async () => {
|
|
vi.resetModules()
|
|
const { POST } = await import('@/app/(payload)/api/auth/login/route')
|
|
|
|
// Request without CSRF token
|
|
const req = createMockRequest({
|
|
method: 'POST',
|
|
url: 'https://test.example.com/api/auth/login',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
origin: 'https://test.example.com',
|
|
},
|
|
ip: randomIp(),
|
|
})
|
|
|
|
// Mock json() method
|
|
Object.defineProperty(req, 'json', {
|
|
value: vi.fn().mockResolvedValue({
|
|
email: 'test@example.com',
|
|
password: 'password123',
|
|
}),
|
|
})
|
|
|
|
const response = await POST(req)
|
|
|
|
expect(response.status).toBe(403)
|
|
const body = await response.json()
|
|
expect(body.error || body.errors?.[0]?.message).toContain('CSRF')
|
|
})
|
|
|
|
it('allows login with valid CSRF token', async () => {
|
|
vi.resetModules()
|
|
const { POST } = await import('@/app/(payload)/api/auth/login/route')
|
|
|
|
const token = generateTestCsrfToken('test-csrf-secret-for-testing')
|
|
const req = createMockRequest({
|
|
method: 'POST',
|
|
url: 'https://test.example.com/api/auth/login',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
origin: 'https://test.example.com',
|
|
'X-CSRF-Token': token,
|
|
},
|
|
cookies: {
|
|
'csrf-token': token,
|
|
},
|
|
ip: randomIp(),
|
|
})
|
|
|
|
// Mock json() method
|
|
Object.defineProperty(req, 'json', {
|
|
value: vi.fn().mockResolvedValue({
|
|
email: 'test@example.com',
|
|
password: 'password123',
|
|
}),
|
|
})
|
|
|
|
const response = await POST(req)
|
|
|
|
// Should proceed past CSRF check (may fail auth, but that's expected)
|
|
expect(response.status).not.toBe(403)
|
|
})
|
|
|
|
it('allows server-to-server login requests without CSRF', async () => {
|
|
vi.resetModules()
|
|
const { POST } = await import('@/app/(payload)/api/auth/login/route')
|
|
|
|
// Server-to-server: Authorization header, JSON content-type, no Origin
|
|
const req = createMockRequest({
|
|
method: 'POST',
|
|
url: 'https://test.example.com/api/auth/login',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
authorization: 'Bearer api-token',
|
|
// No origin header
|
|
},
|
|
ip: randomIp(),
|
|
})
|
|
|
|
// Mock json() method
|
|
Object.defineProperty(req, 'json', {
|
|
value: vi.fn().mockResolvedValue({
|
|
email: 'test@example.com',
|
|
password: 'password123',
|
|
}),
|
|
})
|
|
|
|
const response = await POST(req)
|
|
|
|
// Should not be CSRF blocked
|
|
expect(response.status).not.toBe(403)
|
|
})
|
|
})
|
|
|
|
describe('Send Email Endpoint', () => {
|
|
it('requires CSRF for browser requests', async () => {
|
|
vi.resetModules()
|
|
const { POST } = await import('@/app/(payload)/api/send-email/route')
|
|
|
|
const req = createMockRequest({
|
|
method: 'POST',
|
|
url: 'https://test.example.com/api/send-email',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
origin: 'https://test.example.com',
|
|
},
|
|
ip: randomIp(),
|
|
})
|
|
|
|
// Mock json() method
|
|
Object.defineProperty(req, 'json', {
|
|
value: vi.fn().mockResolvedValue({
|
|
tenantId: 1,
|
|
to: 'recipient@example.com',
|
|
subject: 'Test',
|
|
html: '<p>Test</p>',
|
|
}),
|
|
})
|
|
|
|
const response = await POST(req)
|
|
|
|
expect(response.status).toBe(403)
|
|
})
|
|
})
|
|
|
|
describe('Token Validation', () => {
|
|
it('rejects expired CSRF tokens', async () => {
|
|
vi.resetModules()
|
|
const { POST } = await import('@/app/(payload)/api/auth/login/route')
|
|
|
|
const expiredToken = generateExpiredCsrfToken('test-csrf-secret-for-testing')
|
|
const req = createMockRequest({
|
|
method: 'POST',
|
|
url: 'https://test.example.com/api/auth/login',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
origin: 'https://test.example.com',
|
|
'X-CSRF-Token': expiredToken,
|
|
},
|
|
cookies: {
|
|
'csrf-token': expiredToken,
|
|
},
|
|
ip: randomIp(),
|
|
})
|
|
|
|
Object.defineProperty(req, 'json', {
|
|
value: vi.fn().mockResolvedValue({
|
|
email: 'test@example.com',
|
|
password: 'password123',
|
|
}),
|
|
})
|
|
|
|
const response = await POST(req)
|
|
|
|
expect(response.status).toBe(403)
|
|
})
|
|
|
|
it('rejects invalid CSRF tokens', async () => {
|
|
vi.resetModules()
|
|
const { POST } = await import('@/app/(payload)/api/auth/login/route')
|
|
|
|
const invalidToken = generateInvalidCsrfToken()
|
|
const req = createMockRequest({
|
|
method: 'POST',
|
|
url: 'https://test.example.com/api/auth/login',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
origin: 'https://test.example.com',
|
|
'X-CSRF-Token': invalidToken,
|
|
},
|
|
cookies: {
|
|
'csrf-token': invalidToken,
|
|
},
|
|
ip: randomIp(),
|
|
})
|
|
|
|
Object.defineProperty(req, 'json', {
|
|
value: vi.fn().mockResolvedValue({
|
|
email: 'test@example.com',
|
|
password: 'password123',
|
|
}),
|
|
})
|
|
|
|
const response = await POST(req)
|
|
|
|
expect(response.status).toBe(403)
|
|
})
|
|
|
|
it('rejects mismatched header and cookie tokens', async () => {
|
|
vi.resetModules()
|
|
const { POST } = await import('@/app/(payload)/api/auth/login/route')
|
|
|
|
const token1 = generateTestCsrfToken('test-csrf-secret-for-testing')
|
|
const token2 = generateTestCsrfToken('test-csrf-secret-for-testing')
|
|
|
|
const req = createMockRequest({
|
|
method: 'POST',
|
|
url: 'https://test.example.com/api/auth/login',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
origin: 'https://test.example.com',
|
|
'X-CSRF-Token': token1,
|
|
},
|
|
cookies: {
|
|
'csrf-token': token2, // Different token
|
|
},
|
|
ip: randomIp(),
|
|
})
|
|
|
|
Object.defineProperty(req, 'json', {
|
|
value: vi.fn().mockResolvedValue({
|
|
email: 'test@example.com',
|
|
password: 'password123',
|
|
}),
|
|
})
|
|
|
|
const response = await POST(req)
|
|
|
|
expect(response.status).toBe(403)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('CSRF Token Endpoint', () => {
|
|
it('returns new CSRF token', async () => {
|
|
vi.resetModules()
|
|
const { GET } = await import('@/app/(frontend)/api/csrf-token/route')
|
|
|
|
const req = createMockRequest({
|
|
method: 'GET',
|
|
url: 'https://test.example.com/api/csrf-token',
|
|
ip: randomIp(),
|
|
})
|
|
|
|
const response = await GET(req)
|
|
|
|
expect(response.status).toBe(200)
|
|
const body = await response.json()
|
|
expect(body.success).toBe(true)
|
|
expect(body.token).toBeDefined()
|
|
expect(body.header).toBe('X-CSRF-Token')
|
|
expect(body.cookie).toBe('csrf-token')
|
|
})
|
|
|
|
it('sets CSRF cookie in response', async () => {
|
|
vi.resetModules()
|
|
const { GET } = await import('@/app/(frontend)/api/csrf-token/route')
|
|
|
|
const req = createMockRequest({
|
|
method: 'GET',
|
|
url: 'https://test.example.com/api/csrf-token',
|
|
ip: randomIp(),
|
|
})
|
|
|
|
const response = await GET(req)
|
|
|
|
// Check for Set-Cookie header
|
|
const setCookie = response.headers.get('set-cookie')
|
|
expect(setCookie).toContain('csrf-token')
|
|
})
|
|
|
|
it('includes rate limit headers', async () => {
|
|
vi.resetModules()
|
|
const { GET } = await import('@/app/(frontend)/api/csrf-token/route')
|
|
|
|
const req = createMockRequest({
|
|
method: 'GET',
|
|
url: 'https://test.example.com/api/csrf-token',
|
|
ip: randomIp(),
|
|
})
|
|
|
|
const response = await GET(req)
|
|
|
|
expect(response.headers.has('X-RateLimit-Remaining')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('Safe Methods', () => {
|
|
it('allows GET requests without CSRF token', async () => {
|
|
vi.resetModules()
|
|
const { GET } = await import('@/app/(frontend)/api/posts/route')
|
|
|
|
const req = createMockRequest({
|
|
method: 'GET',
|
|
url: 'https://test.example.com/api/posts',
|
|
ip: randomIp(),
|
|
})
|
|
|
|
const response = await GET(req)
|
|
|
|
// GET should work without CSRF
|
|
expect(response.status).not.toBe(403)
|
|
})
|
|
})
|
|
|
|
describe('Origin Validation', () => {
|
|
it('rejects requests from unauthorized origins', async () => {
|
|
vi.resetModules()
|
|
const { POST } = await import('@/app/(payload)/api/auth/login/route')
|
|
|
|
const token = generateTestCsrfToken('test-csrf-secret-for-testing')
|
|
const req = createMockRequest({
|
|
method: 'POST',
|
|
url: 'https://test.example.com/api/auth/login',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
origin: 'https://malicious-site.com',
|
|
'X-CSRF-Token': token,
|
|
},
|
|
cookies: {
|
|
'csrf-token': token,
|
|
},
|
|
ip: randomIp(),
|
|
})
|
|
|
|
Object.defineProperty(req, 'json', {
|
|
value: vi.fn().mockResolvedValue({
|
|
email: 'test@example.com',
|
|
password: 'password123',
|
|
}),
|
|
})
|
|
|
|
const response = await POST(req)
|
|
|
|
expect(response.status).toBe(403)
|
|
})
|
|
})
|
|
})
|