cms.c2sgmbh/tests/unit/security/ip-allowlist.unit.spec.ts
Martin Porwoll 63b97c14f2 feat(security): enhance CSRF, IP allowlist, and rate limiter with strict production checks
- 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>
2025-12-18 05:06:15 +00:00

418 lines
12 KiB
TypeScript

/**
* IP Allowlist/Blocklist Unit Tests
*
* Tests for the IP access control module.
* Covers CIDR matching, wildcards, allowlist/blocklist logic, and IP extraction.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { NextRequest } from 'next/server'
describe('IP Allowlist', () => {
beforeEach(() => {
// Reset environment variables
vi.unstubAllEnvs()
})
afterEach(() => {
vi.unstubAllEnvs()
})
describe('getClientIpFromRequest', () => {
// Import dynamically to reset mocks
async function getModule() {
vi.resetModules()
return import('@/lib/security/ip-allowlist')
}
describe('with TRUST_PROXY=true', () => {
beforeEach(() => {
vi.stubEnv('TRUST_PROXY', 'true')
})
it('extracts IP from x-forwarded-for header', async () => {
const { getClientIpFromRequest } = await getModule()
const req = new NextRequest('https://example.com/api/test', {
headers: {
'x-forwarded-for': '203.0.113.50, 70.41.3.18',
},
})
const ip = getClientIpFromRequest(req)
expect(ip).toBe('203.0.113.50')
})
it('extracts IP from x-real-ip header', async () => {
const { getClientIpFromRequest } = await getModule()
const req = new NextRequest('https://example.com/api/test', {
headers: {
'x-real-ip': '192.168.1.100',
},
})
const ip = getClientIpFromRequest(req)
expect(ip).toBe('192.168.1.100')
})
it('prefers x-forwarded-for over x-real-ip', async () => {
const { getClientIpFromRequest } = await getModule()
const req = new NextRequest('https://example.com/api/test', {
headers: {
'x-forwarded-for': '10.0.0.1',
'x-real-ip': '10.0.0.2',
},
})
const ip = getClientIpFromRequest(req)
expect(ip).toBe('10.0.0.1')
})
})
describe('without TRUST_PROXY (default - prevents IP spoofing)', () => {
beforeEach(() => {
vi.stubEnv('TRUST_PROXY', '')
})
it('ignores x-forwarded-for header to prevent IP spoofing', async () => {
const { getClientIpFromRequest } = await getModule()
const req = new NextRequest('https://example.com/api/test', {
headers: {
'x-forwarded-for': '203.0.113.50, 70.41.3.18',
},
})
const ip = getClientIpFromRequest(req)
expect(ip).toBe('direct-connection')
})
it('ignores x-real-ip header to prevent IP spoofing', async () => {
const { getClientIpFromRequest } = await getModule()
const req = new NextRequest('https://example.com/api/test', {
headers: {
'x-real-ip': '192.168.1.100',
},
})
const ip = getClientIpFromRequest(req)
expect(ip).toBe('direct-connection')
})
it('returns direct-connection for missing headers', async () => {
const { getClientIpFromRequest } = await getModule()
const req = new NextRequest('https://example.com/api/test')
const ip = getClientIpFromRequest(req)
expect(ip).toBe('direct-connection')
})
})
})
describe('isIpBlocked', () => {
async function getModule() {
vi.resetModules()
return import('@/lib/security/ip-allowlist')
}
it('returns false when BLOCKED_IPS is not set', async () => {
const { isIpBlocked } = await getModule()
const result = isIpBlocked('192.168.1.1')
expect(result).toBe(false)
})
it('blocks exact IP match', async () => {
vi.stubEnv('BLOCKED_IPS', '192.168.1.100,10.0.0.50')
const { isIpBlocked } = await getModule()
expect(isIpBlocked('192.168.1.100')).toBe(true)
expect(isIpBlocked('10.0.0.50')).toBe(true)
expect(isIpBlocked('192.168.1.101')).toBe(false)
})
it('blocks CIDR range', async () => {
vi.stubEnv('BLOCKED_IPS', '10.0.0.0/24')
const { isIpBlocked } = await getModule()
expect(isIpBlocked('10.0.0.1')).toBe(true)
expect(isIpBlocked('10.0.0.255')).toBe(true)
expect(isIpBlocked('10.0.1.1')).toBe(false)
})
it('blocks wildcard pattern', async () => {
vi.stubEnv('BLOCKED_IPS', '192.168.*')
const { isIpBlocked } = await getModule()
expect(isIpBlocked('192.168.0.1')).toBe(true)
expect(isIpBlocked('192.168.255.255')).toBe(true)
expect(isIpBlocked('192.169.0.1')).toBe(false)
})
it('handles multiple patterns', async () => {
vi.stubEnv('BLOCKED_IPS', '10.0.0.0/8,192.168.1.50,172.16.*')
const { isIpBlocked } = await getModule()
expect(isIpBlocked('10.5.5.5')).toBe(true) // CIDR
expect(isIpBlocked('192.168.1.50')).toBe(true) // Exact
expect(isIpBlocked('172.16.0.1')).toBe(true) // Wildcard
expect(isIpBlocked('8.8.8.8')).toBe(false)
})
})
describe('isIpAllowed', () => {
async function getModule() {
vi.resetModules()
return import('@/lib/security/ip-allowlist')
}
describe('sendEmail endpoint', () => {
it('allows all IPs when SEND_EMAIL_ALLOWED_IPS is not set', async () => {
const { isIpAllowed } = await getModule()
expect(isIpAllowed('192.168.1.1', 'sendEmail').allowed).toBe(true)
expect(isIpAllowed('10.0.0.1', 'sendEmail').allowed).toBe(true)
})
it('restricts to allowlist when configured', async () => {
vi.stubEnv('SEND_EMAIL_ALLOWED_IPS', '192.168.1.0/24,10.0.0.50')
const { isIpAllowed } = await getModule()
expect(isIpAllowed('192.168.1.100', 'sendEmail').allowed).toBe(true)
expect(isIpAllowed('10.0.0.50', 'sendEmail').allowed).toBe(true)
expect(isIpAllowed('172.16.0.1', 'sendEmail').allowed).toBe(false)
})
})
describe('admin endpoint', () => {
it('allows all IPs when ADMIN_ALLOWED_IPS is not set', async () => {
const { isIpAllowed } = await getModule()
expect(isIpAllowed('192.168.1.1', 'admin').allowed).toBe(true)
})
it('restricts to allowlist when configured', async () => {
vi.stubEnv('ADMIN_ALLOWED_IPS', '10.0.0.0/8')
const { isIpAllowed } = await getModule()
expect(isIpAllowed('10.5.5.5', 'admin').allowed).toBe(true)
expect(isIpAllowed('192.168.1.1', 'admin').allowed).toBe(false)
})
})
describe('webhooks endpoint', () => {
it('blocks all IPs when WEBHOOK_ALLOWED_IPS is not set', async () => {
const { isIpAllowed } = await getModule()
expect(isIpAllowed('192.168.1.1', 'webhooks').allowed).toBe(false)
})
it('allows only configured IPs', async () => {
vi.stubEnv('WEBHOOK_ALLOWED_IPS', '203.0.113.0/24')
const { isIpAllowed } = await getModule()
expect(isIpAllowed('203.0.113.50', 'webhooks').allowed).toBe(true)
expect(isIpAllowed('198.51.100.1', 'webhooks').allowed).toBe(false)
})
})
})
describe('validateIpAccess', () => {
async function getModule() {
vi.resetModules()
return import('@/lib/security/ip-allowlist')
}
// These tests require TRUST_PROXY to extract IP from headers
beforeEach(() => {
vi.stubEnv('TRUST_PROXY', 'true')
})
it('blocks if IP is on blocklist', async () => {
vi.stubEnv('BLOCKED_IPS', '192.168.1.100')
vi.stubEnv('SEND_EMAIL_ALLOWED_IPS', '192.168.1.0/24')
const { validateIpAccess } = await getModule()
const req = new NextRequest('https://example.com/api/send-email', {
headers: {
'x-forwarded-for': '192.168.1.100',
},
})
const result = validateIpAccess(req, 'sendEmail')
expect(result.allowed).toBe(false)
expect(result.reason).toContain('blocked')
})
it('allows if not blocked and on allowlist', async () => {
vi.stubEnv('SEND_EMAIL_ALLOWED_IPS', '192.168.1.0/24')
const { validateIpAccess } = await getModule()
const req = new NextRequest('https://example.com/api/send-email', {
headers: {
'x-forwarded-for': '192.168.1.50',
},
})
const result = validateIpAccess(req, 'sendEmail')
expect(result.allowed).toBe(true)
})
it('blocks if not on allowlist (when allowlist configured)', async () => {
vi.stubEnv('SEND_EMAIL_ALLOWED_IPS', '10.0.0.0/8')
const { validateIpAccess } = await getModule()
const req = new NextRequest('https://example.com/api/send-email', {
headers: {
'x-forwarded-for': '192.168.1.50',
},
})
const result = validateIpAccess(req, 'sendEmail')
expect(result.allowed).toBe(false)
expect(result.reason).toContain('not in allowlist')
})
it('returns extracted IP in result', async () => {
const { validateIpAccess } = await getModule()
const req = new NextRequest('https://example.com/api/test', {
headers: {
'x-forwarded-for': '203.0.113.50',
},
})
const result = validateIpAccess(req, 'sendEmail')
expect(result.ip).toBe('203.0.113.50')
})
it('returns direct-connection when TRUST_PROXY is not set', async () => {
vi.stubEnv('TRUST_PROXY', '')
const { validateIpAccess } = await getModule()
const req = new NextRequest('https://example.com/api/test', {
headers: {
'x-forwarded-for': '203.0.113.50',
},
})
const result = validateIpAccess(req, 'sendEmail')
// Without TRUST_PROXY, IP spoofing is prevented by ignoring headers
expect(result.ip).toBe('direct-connection')
})
})
describe('CIDR Matching', () => {
async function getModule() {
vi.resetModules()
return import('@/lib/security/ip-allowlist')
}
it('handles /32 (single IP)', async () => {
vi.stubEnv('BLOCKED_IPS', '192.168.1.1/32')
const { isIpBlocked } = await getModule()
expect(isIpBlocked('192.168.1.1')).toBe(true)
expect(isIpBlocked('192.168.1.2')).toBe(false)
})
it('handles /24 (256 IPs)', async () => {
vi.stubEnv('BLOCKED_IPS', '10.20.30.0/24')
const { isIpBlocked } = await getModule()
expect(isIpBlocked('10.20.30.0')).toBe(true)
expect(isIpBlocked('10.20.30.127')).toBe(true)
expect(isIpBlocked('10.20.30.255')).toBe(true)
expect(isIpBlocked('10.20.31.0')).toBe(false)
})
it('handles /16 (65536 IPs)', async () => {
vi.stubEnv('BLOCKED_IPS', '172.16.0.0/16')
const { isIpBlocked } = await getModule()
expect(isIpBlocked('172.16.0.1')).toBe(true)
expect(isIpBlocked('172.16.255.255')).toBe(true)
expect(isIpBlocked('172.17.0.1')).toBe(false)
})
it('handles /8 (16M IPs)', async () => {
vi.stubEnv('BLOCKED_IPS', '10.0.0.0/8')
const { isIpBlocked } = await getModule()
expect(isIpBlocked('10.0.0.1')).toBe(true)
expect(isIpBlocked('10.255.255.255')).toBe(true)
expect(isIpBlocked('11.0.0.1')).toBe(false)
})
it('handles /0 (all IPs)', async () => {
vi.stubEnv('BLOCKED_IPS', '0.0.0.0/0')
const { isIpBlocked } = await getModule()
expect(isIpBlocked('192.168.1.1')).toBe(true)
expect(isIpBlocked('8.8.8.8')).toBe(true)
expect(isIpBlocked('10.0.0.1')).toBe(true)
})
})
describe('Edge Cases', () => {
async function getModule() {
vi.resetModules()
return import('@/lib/security/ip-allowlist')
}
it('handles empty allowlist string', async () => {
vi.stubEnv('SEND_EMAIL_ALLOWED_IPS', '')
const { isIpAllowed } = await getModule()
// Empty string should be treated as not configured
expect(isIpAllowed('192.168.1.1', 'sendEmail').allowed).toBe(true)
})
it('handles whitespace in IP list', async () => {
vi.stubEnv('BLOCKED_IPS', ' 192.168.1.1 , 10.0.0.1 ')
const { isIpBlocked } = await getModule()
expect(isIpBlocked('192.168.1.1')).toBe(true)
expect(isIpBlocked('10.0.0.1')).toBe(true)
})
it('handles invalid CIDR notation gracefully', async () => {
vi.stubEnv('BLOCKED_IPS', '192.168.1.0/invalid')
const { isIpBlocked } = await getModule()
// Should not throw, but may not match
expect(() => isIpBlocked('192.168.1.1')).not.toThrow()
})
it('handles IPv4-mapped IPv6 addresses', async () => {
vi.stubEnv('BLOCKED_IPS', '192.168.1.1')
const { isIpBlocked } = await getModule()
// Standard IPv4 should work
expect(isIpBlocked('192.168.1.1')).toBe(true)
})
it('handles unknown IP string', async () => {
vi.stubEnv('BLOCKED_IPS', '192.168.1.1')
const { isIpBlocked } = await getModule()
expect(isIpBlocked('unknown')).toBe(false)
})
})
})