mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 23:14: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>
357 lines
11 KiB
TypeScript
357 lines
11 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')
|
|
}
|
|
|
|
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')
|
|
})
|
|
|
|
it('returns unknown for missing headers', async () => {
|
|
const { getClientIpFromRequest } = await getModule()
|
|
|
|
const req = new NextRequest('https://example.com/api/test')
|
|
|
|
const ip = getClientIpFromRequest(req)
|
|
|
|
expect(ip).toBe('unknown')
|
|
})
|
|
})
|
|
|
|
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')
|
|
}
|
|
|
|
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')
|
|
})
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|
|
})
|