mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 17:24:12 +00:00
- CSRF: Require CSRF_SECRET in production, throw error on missing secret - IP Allowlist: TRUST_PROXY must be explicitly set to 'true' for proxy headers - Rate Limiter: Add proper proxy trust handling for client IP detection - Login: Add browser form redirect support with safe URL validation - Add custom admin login page with styled form - Update CLAUDE.md with TRUST_PROXY documentation - Update tests for new security behavior BREAKING: Server will not start in production without CSRF_SECRET or PAYLOAD_SECRET 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
375 lines
9.9 KiB
TypeScript
375 lines
9.9 KiB
TypeScript
/**
|
|
* Rate Limiter Unit Tests
|
|
*
|
|
* Tests for the central rate limiting module.
|
|
* Covers in-memory store, IP extraction, header generation, and Redis fallback.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
|
|
// Mock Redis before importing the module
|
|
vi.mock('ioredis', () => ({
|
|
default: vi.fn().mockImplementation(() => ({
|
|
incr: vi.fn(),
|
|
pexpire: vi.fn(),
|
|
pttl: vi.fn(),
|
|
keys: vi.fn(),
|
|
del: vi.fn(),
|
|
quit: vi.fn(),
|
|
on: vi.fn(),
|
|
})),
|
|
}))
|
|
|
|
// Mock the redis module
|
|
vi.mock('@/lib/redis', () => ({
|
|
getRedisClient: vi.fn(() => null),
|
|
isRedisAvailable: vi.fn(() => false),
|
|
}))
|
|
|
|
import {
|
|
createRateLimiter,
|
|
getClientIp,
|
|
rateLimitHeaders,
|
|
publicApiLimiter,
|
|
authLimiter,
|
|
searchLimiter,
|
|
} from '@/lib/security/rate-limiter'
|
|
|
|
describe('Rate Limiter', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
vi.useFakeTimers()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
describe('createRateLimiter', () => {
|
|
it('creates a limiter with custom configuration', () => {
|
|
const limiter = createRateLimiter({
|
|
name: 'test-limiter',
|
|
maxRequests: 10,
|
|
windowMs: 60000,
|
|
})
|
|
|
|
expect(limiter).toBeDefined()
|
|
expect(limiter.check).toBeDefined()
|
|
expect(typeof limiter.check).toBe('function')
|
|
})
|
|
|
|
it('allows requests within limit', async () => {
|
|
const limiter = createRateLimiter({
|
|
name: 'test-allow',
|
|
maxRequests: 5,
|
|
windowMs: 60000,
|
|
})
|
|
|
|
const result = await limiter.check('test-ip-1')
|
|
|
|
expect(result.allowed).toBe(true)
|
|
expect(result.remaining).toBe(4) // 5 - 1
|
|
})
|
|
|
|
it('tracks requests per identifier', async () => {
|
|
const limiter = createRateLimiter({
|
|
name: 'test-track',
|
|
maxRequests: 3,
|
|
windowMs: 60000,
|
|
})
|
|
|
|
// First request
|
|
const result1 = await limiter.check('user-a')
|
|
expect(result1.remaining).toBe(2)
|
|
|
|
// Second request same user
|
|
const result2 = await limiter.check('user-a')
|
|
expect(result2.remaining).toBe(1)
|
|
|
|
// Different user should have full quota
|
|
const result3 = await limiter.check('user-b')
|
|
expect(result3.remaining).toBe(2)
|
|
})
|
|
|
|
it('blocks requests exceeding limit', async () => {
|
|
const limiter = createRateLimiter({
|
|
name: 'test-block',
|
|
maxRequests: 3,
|
|
windowMs: 60000,
|
|
})
|
|
|
|
// Exhaust the limit
|
|
await limiter.check('blocked-ip')
|
|
await limiter.check('blocked-ip')
|
|
await limiter.check('blocked-ip')
|
|
|
|
// Next request should be blocked
|
|
const result = await limiter.check('blocked-ip')
|
|
|
|
expect(result.allowed).toBe(false)
|
|
expect(result.remaining).toBe(0)
|
|
expect(result.retryAfter).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('resets after window expires', async () => {
|
|
const windowMs = 60000
|
|
const limiter = createRateLimiter({
|
|
name: 'test-reset',
|
|
maxRequests: 2,
|
|
windowMs,
|
|
})
|
|
|
|
// Exhaust the limit
|
|
await limiter.check('reset-ip')
|
|
await limiter.check('reset-ip')
|
|
const blockedResult = await limiter.check('reset-ip')
|
|
expect(blockedResult.allowed).toBe(false)
|
|
|
|
// Advance time past window
|
|
vi.advanceTimersByTime(windowMs + 1000)
|
|
|
|
// Should be allowed again
|
|
const result = await limiter.check('reset-ip')
|
|
expect(result.allowed).toBe(true)
|
|
expect(result.remaining).toBe(1)
|
|
})
|
|
|
|
it('provides correct resetIn value', async () => {
|
|
const windowMs = 60000
|
|
const limiter = createRateLimiter({
|
|
name: 'test-resetin',
|
|
maxRequests: 5,
|
|
windowMs,
|
|
})
|
|
|
|
const result = await limiter.check('resetin-ip')
|
|
|
|
// resetIn should be close to windowMs
|
|
expect(result.resetIn).toBeLessThanOrEqual(windowMs)
|
|
expect(result.resetIn).toBeGreaterThan(windowMs - 1000)
|
|
})
|
|
})
|
|
|
|
describe('getClientIp', () => {
|
|
describe('with TRUST_PROXY=true', () => {
|
|
beforeEach(() => {
|
|
vi.stubEnv('TRUST_PROXY', 'true')
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs()
|
|
})
|
|
|
|
it('extracts IP from x-forwarded-for header', () => {
|
|
const headers = new Headers()
|
|
headers.set('x-forwarded-for', '203.0.113.50, 70.41.3.18, 150.172.238.178')
|
|
|
|
const ip = getClientIp(headers)
|
|
|
|
expect(ip).toBe('203.0.113.50')
|
|
})
|
|
|
|
it('extracts IP from x-real-ip header', () => {
|
|
const headers = new Headers()
|
|
headers.set('x-real-ip', '192.168.1.100')
|
|
|
|
const ip = getClientIp(headers)
|
|
|
|
expect(ip).toBe('192.168.1.100')
|
|
})
|
|
|
|
it('prefers x-forwarded-for over x-real-ip', () => {
|
|
const headers = new Headers()
|
|
headers.set('x-forwarded-for', '10.0.0.1')
|
|
headers.set('x-real-ip', '10.0.0.2')
|
|
|
|
const ip = getClientIp(headers)
|
|
|
|
expect(ip).toBe('10.0.0.1')
|
|
})
|
|
|
|
it('trims whitespace from forwarded IPs', () => {
|
|
const headers = new Headers()
|
|
headers.set('x-forwarded-for', ' 192.168.1.1 , 10.0.0.1')
|
|
|
|
const ip = getClientIp(headers)
|
|
|
|
expect(ip).toBe('192.168.1.1')
|
|
})
|
|
})
|
|
|
|
describe('without TRUST_PROXY (default)', () => {
|
|
beforeEach(() => {
|
|
vi.stubEnv('TRUST_PROXY', '')
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs()
|
|
})
|
|
|
|
it('ignores x-forwarded-for header to prevent IP spoofing', () => {
|
|
const headers = new Headers()
|
|
headers.set('x-forwarded-for', '203.0.113.50')
|
|
|
|
const ip = getClientIp(headers)
|
|
|
|
expect(ip).toBe('direct-connection')
|
|
})
|
|
|
|
it('ignores x-real-ip header to prevent IP spoofing', () => {
|
|
const headers = new Headers()
|
|
headers.set('x-real-ip', '192.168.1.100')
|
|
|
|
const ip = getClientIp(headers)
|
|
|
|
expect(ip).toBe('direct-connection')
|
|
})
|
|
|
|
it('returns direct-connection for missing headers', () => {
|
|
const headers = new Headers()
|
|
|
|
const ip = getClientIp(headers)
|
|
|
|
expect(ip).toBe('direct-connection')
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('rateLimitHeaders', () => {
|
|
it('generates correct headers for allowed request', () => {
|
|
const result = {
|
|
allowed: true,
|
|
remaining: 25,
|
|
resetIn: 45000,
|
|
}
|
|
|
|
const headers = rateLimitHeaders(result, 30) as Record<string, string>
|
|
|
|
expect(headers['X-RateLimit-Limit']).toBe('30')
|
|
expect(headers['X-RateLimit-Remaining']).toBe('25')
|
|
expect(headers['X-RateLimit-Reset']).toBeDefined()
|
|
})
|
|
|
|
it('includes Retry-After for blocked request', () => {
|
|
const result = {
|
|
allowed: false,
|
|
remaining: 0,
|
|
resetIn: 30000,
|
|
retryAfter: 30,
|
|
}
|
|
|
|
const headers = rateLimitHeaders(result, 10) as Record<string, string>
|
|
|
|
expect(headers['Retry-After']).toBe('30')
|
|
expect(headers['X-RateLimit-Remaining']).toBe('0')
|
|
})
|
|
|
|
it('calculates reset timestamp correctly', () => {
|
|
const result = {
|
|
allowed: true,
|
|
remaining: 5,
|
|
resetIn: 60000,
|
|
}
|
|
|
|
const headers = rateLimitHeaders(result, 10) as Record<string, string>
|
|
const resetValue = headers['X-RateLimit-Reset']
|
|
|
|
// The reset value should be a number (either timestamp or seconds)
|
|
expect(resetValue).toBeDefined()
|
|
expect(parseInt(resetValue, 10)).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
|
|
describe('Predefined Limiters', () => {
|
|
it('publicApiLimiter allows 60 requests per minute', async () => {
|
|
const result = await publicApiLimiter.check('public-test')
|
|
|
|
expect(result.allowed).toBe(true)
|
|
expect(result.remaining).toBe(59)
|
|
})
|
|
|
|
it('authLimiter allows 5 requests per 15 minutes', async () => {
|
|
const result = await authLimiter.check('auth-test')
|
|
|
|
expect(result.allowed).toBe(true)
|
|
expect(result.remaining).toBe(4)
|
|
})
|
|
|
|
it('searchLimiter allows 30 requests per minute', async () => {
|
|
const result = await searchLimiter.check('search-test')
|
|
|
|
expect(result.allowed).toBe(true)
|
|
expect(result.remaining).toBe(29)
|
|
})
|
|
})
|
|
|
|
describe('Memory Store Cleanup', () => {
|
|
it('removes expired entries', async () => {
|
|
const windowMs = 1000 // 1 second window
|
|
const limiter = createRateLimiter({
|
|
name: 'cleanup-test',
|
|
maxRequests: 10,
|
|
windowMs,
|
|
})
|
|
|
|
// Create entries
|
|
await limiter.check('cleanup-ip-1')
|
|
await limiter.check('cleanup-ip-2')
|
|
|
|
// Advance past expiry (2x window + cleanup interval)
|
|
vi.advanceTimersByTime(windowMs * 2 + 300001)
|
|
|
|
// New requests should have full quota (entries cleaned up)
|
|
const result = await limiter.check('cleanup-ip-1')
|
|
expect(result.remaining).toBe(9)
|
|
})
|
|
})
|
|
|
|
describe('Edge Cases', () => {
|
|
it('handles empty identifier', async () => {
|
|
const limiter = createRateLimiter({
|
|
name: 'empty-id-test',
|
|
maxRequests: 5,
|
|
windowMs: 60000,
|
|
})
|
|
|
|
const result = await limiter.check('')
|
|
|
|
expect(result.allowed).toBe(true)
|
|
})
|
|
|
|
it('handles special characters in identifier', async () => {
|
|
const limiter = createRateLimiter({
|
|
name: 'special-char-test',
|
|
maxRequests: 5,
|
|
windowMs: 60000,
|
|
})
|
|
|
|
const result = await limiter.check('user@example.com:192.168.1.1')
|
|
|
|
expect(result.allowed).toBe(true)
|
|
expect(result.remaining).toBe(4)
|
|
})
|
|
|
|
it('handles concurrent requests', async () => {
|
|
const limiter = createRateLimiter({
|
|
name: 'concurrent-test',
|
|
maxRequests: 10,
|
|
windowMs: 60000,
|
|
})
|
|
|
|
// Fire multiple requests concurrently
|
|
const promises = Array.from({ length: 5 }, () => limiter.check('concurrent-ip'))
|
|
const results = await Promise.all(promises)
|
|
|
|
// All should be allowed, remaining should decrement
|
|
results.forEach((result) => {
|
|
expect(result.allowed).toBe(true)
|
|
})
|
|
|
|
// Check total consumed
|
|
const finalResult = await limiter.check('concurrent-ip')
|
|
expect(finalResult.remaining).toBe(4) // 10 - 5 - 1 = 4
|
|
})
|
|
})
|
|
})
|