import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { createServer } from 'node:http' import type { AddressInfo } from 'node:net' import { createMemoryDb, runMigrations, repos } from '@evofw/db' import * as safe from './safe-fetch.js' import { fetchEvobgpPages } from './evobgp.js' import { refreshIpList } from './lists/refresh.js' import { publishListCascade } from './lists/entries.js' const server = createServer((req, res) => { const path = req.url! if (path === '/large') { res.write('['); res.end(' '.repeat(1024) + ']'); return } if (path === '/declared') { res.setHeader('Content-Length', '100000'); res.end('[]'); return } if (path === '/headers') { res.setHeader('X-Large', 'x'.repeat(20_000)); res.end('{}'); return } if (path === '/slow') { res.write('['); setTimeout(() => res.end(']'), 150); return } if (path === '/invalid') { res.end('not JSON'); return } if (path === '/compressed') { res.setHeader('Content-Encoding', 'gzip'); res.end('[]'); return } if (path === '/redirect') { res.writeHead(302, { Location: '/echo' }); res.end(); return } if (path === '/loop') { res.writeHead(302, { Location: '/loop' }); res.end(); return } if (path === '/private') { res.writeHead(302, { Location: 'http://127.0.0.2/secret' }); res.end(); return } if (path === '/cross') { res.writeHead(302, { Location: `${numericOrigin}/echo` }); res.end(); return } if (path === '/cross-back') { res.writeHead(302, { Location: `${numericOrigin}/back` }); res.end(); return } if (path === '/back') { res.writeHead(302, { Location: `${origin}/echo` }); res.end(); return } res.setHeader('Content-Type', 'application/json') res.end(JSON.stringify({ authorization: req.headers.authorization || null, cookie: req.headers.cookie || null })) }) let origin: string let numericOrigin: string const databases: ReturnType[] = [] beforeAll(async () => { await new Promise(resolve => server.listen(0, resolve)) const port = (server.address() as AddressInfo).port origin = `http://localhost:${port}` numericOrigin = `http://127.0.0.1:${port}` }) afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs() for (const memory of databases.splice(0)) memory.sqlite.close() }) afterAll(async () => { server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())) }) function allow() { vi.stubEnv('EVOFW_SOURCE_ALLOWED_ORIGINS', `${origin},${numericOrigin}`) } function memory() { const value = createMemoryDb(); runMigrations(value.sqlite); databases.push(value); return value } function feed(db: ReturnType['db'], id: string, config: Record = {}) { repos.insertIpList(db, { id, name: id, type: 'json_url', configJson: JSON.stringify({ url: 'https://feed.test/list', ...config }) }) repos.replaceIpListEntries(db, id, ['8.8.8.8/32']) } describe('source socket boundaries', () => { it('rejects private, reserved, mapped and transition IPs; accepts public v4/v6', () => { for (const ip of ['127.0.0.1', '10.1.1.1', '169.254.169.254', '172.16.0.1', '192.168.1.1', '100.64.0.1', '198.18.0.1', '224.1.1.1', '::1', 'fc00::1', 'fe80::1', '::ffff:8.8.8.8', '2002:7f00:1::', '2001:db8::1']) expect(safe.isPublicSourceAddress(ip), ip).toBe(false) for (const ip of ['8.8.8.8', '1.1.1.1', '2606:4700:4700::1111']) expect(safe.isPublicSourceAddress(ip), ip).toBe(true) }) it('returns only the vetted DNS answers to the socket and rejects mixed/private resolution', async () => { const resolver = vi.fn().mockResolvedValue([{ address: '8.8.8.8', family: 4 }]) const call = (all = false) => new Promise((resolve, reject) => safe.sourceLookup(new URL('https://feed.test'), resolver)('feed.test', { all }, (err, address) => err ? reject(err) : resolve(address))) expect(await call()).toBe('8.8.8.8') expect(await call(true)).toEqual([{ address: '8.8.8.8', family: 4 }]) resolver.mockResolvedValue([{ address: '8.8.8.8', family: 4 }, { address: '127.0.0.1', family: 4 }]) await expect(call()).rejects.toThrow('not public') vi.stubEnv('EVOFW_SOURCE_ALLOWED_ORIGINS', '%%%invalid') await expect(call()).rejects.toThrow('policy is invalid') }) it('blocks literal and DNS loopback without explicit origin allowlisting', async () => { await expect(safe.safeFetchJson(`${numericOrigin}/echo`)).rejects.toThrow('not public') await expect(safe.safeFetchJson(`${origin}/echo`)).rejects.toThrow('failed') await expect(safe.safeFetchJson('file:///etc/passwd')).rejects.toThrow('HTTP(S)') await expect(safe.safeFetchJson('https://user:pass@feed.test')).rejects.toThrow('credentials') }) it('enforces chunked/declared byte limits, headers, JSON format, encoding and body deadline', async () => { allow() for (const path of ['/large', '/declared']) await expect(safe.safeFetchJson(`${origin}${path}`, { maxBytes: 32 })).rejects.toThrow('byte limit') await expect(safe.safeFetchJson(`${origin}/headers`)).rejects.toThrow('failed') await expect(safe.safeFetchJson(`${origin}/invalid`)).rejects.toThrow('valid JSON') await expect(safe.safeFetchJson(`${origin}/compressed`)).rejects.toThrow('compressed') await expect(safe.safeFetchJson(`${origin}/slow`, { timeoutMs: 30 })).rejects.toThrow(/interrupted|timed out/) }) it('bounds redirects and strips secrets permanently on an origin change', async () => { allow() const headers = { Authorization: 'Bearer secret', Cookie: 'secret=value' } expect((await safe.safeFetchJson(`${origin}/redirect`, { headers })).data).toEqual({ authorization: 'Bearer secret', cookie: 'secret=value' }) for (const path of ['/cross', '/cross-back']) expect((await safe.safeFetchJson(`${origin}${path}`, { headers })).data).toEqual({ authorization: null, cookie: null }) await expect(safe.safeFetchJson(`${origin}/loop`)).rejects.toThrow('redirect limit') await expect(safe.safeFetchJson(`${origin}/private`, { headers })).rejects.toThrow('not public') }) }) describe('bounded snapshot publication', () => { it('bumps direct port-list agents and deduplicates L3 ancestor dependencies', async () => { const { db, sqlite } = memory() for (const id of ['port-only', 'many-parents']) repos.insertAgent(db, { id, name: id, tokenHash: id, tokenPrefix: id, status: 'approved' }) feed(db, 'child') for (const id of ['parent-a', 'parent-b']) { repos.insertIpList(db, { id, name: id, type: 'static', configJson: JSON.stringify({ items: [{ kind: 'list', value: 'child' }] }) }) repos.insertPolicySet(db, { id: `set-${id}`, name: id }) repos.insertPolicyRule(db, { id: `rule-${id}`, setId: `set-${id}`, priority: 10, action: 'deny', listId: id }) } repos.setAgentPolicySets(db, 'many-parents', ['set-parent-a', 'set-parent-b']) sqlite.prepare('INSERT INTO agent_port_rules(id,agent_id,action,protocol,port_start,port_end,src_kind,list_id) VALUES (?,?,?,?,?,?,?,?)').run('port-list', 'port-only', 'open', 'tcp', 443, 443, 'list', 'child') const beforePort = repos.getAgent(db, 'port-only')!.policyGeneration const beforeMany = repos.getAgent(db, 'many-parents')!.policyGeneration await publishListCascade(db, 'child', { cidrs: ['1.1.1.1'] }) expect(repos.getAgent(db, 'port-only')!.policyGeneration).toBe(beforePort + 1) expect(repos.getAgent(db, 'many-parents')!.policyGeneration).toBe(beforeMany + 1) await publishListCascade(db, 'child', { cidrs: ['1.1.1.1/32'] }) expect(repos.getAgent(db, 'port-only')!.policyGeneration).toBe(beforePort + 1) expect(repos.getAgent(db, 'many-parents')!.policyGeneration).toBe(beforeMany + 1) }) it('quarantines unexpected empty data, preserves last success time and permits explicit empty', async () => { const { db } = memory(); feed(db, 'f') const before = repos.getIpList(db, 'f')!.refreshedAt vi.spyOn(safe, 'safeFetchJson').mockResolvedValue({ data: [], bytes: 2 }) await refreshIpList(db, 'f') expect(repos.listIpListEntries(db, 'f')).toHaveLength(1) expect(repos.getIpList(db, 'f')!.lastError).toContain('quarantined') expect(repos.getIpList(db, 'f')!.refreshedAt).toBe(before) repos.updateIpList(db, 'f', { configJson: JSON.stringify({ url: 'https://feed.test', allow_empty: true }) }) await refreshIpList(db, 'f') expect(repos.listIpListEntries(db, 'f')).toHaveLength(0) expect(repos.getIpList(db, 'f')!.lastError).toBeNull() }) it('discards stale remote results after a config edit', async () => { const { db } = memory(); feed(db, 'f') vi.spyOn(safe, 'safeFetchJson').mockImplementation(async () => { repos.updateIpList(db, 'f', { configJson: JSON.stringify({ url: 'https://new.test' }) }) return { data: ['1.1.1.1'], bytes: 12 } }) await refreshIpList(db, 'f') expect(repos.getIpList(db, 'f')!.configJson).toContain('new.test') expect(repos.listIpListEntries(db, 'f')[0]!.cidr).toBe('8.8.8.8/32') }) it('coalesces the same list and limits global active refreshes to two', async () => { const { db } = memory(); for (const id of ['a', 'b', 'c']) feed(db, id) let active = 0; let maximum = 0 const fetcher = vi.spyOn(safe, 'safeFetchJson').mockImplementation(async () => { active++; maximum = Math.max(active, maximum) await new Promise(resolve => setTimeout(resolve, 10)); active-- return { data: ['1.1.1.1'], bytes: 12 } }) const first = refreshIpList(db, 'a') expect(refreshIpList(db, 'a')).toBe(first) await Promise.all([first, refreshIpList(db, 'b'), refreshIpList(db, 'c')]) expect(maximum).toBe(2); expect(fetcher).toHaveBeenCalledTimes(3) }) it('rejects excessive elements and incomplete/repeated cursor snapshots', async () => { const fetcher = vi.spyOn(safe, 'safeFetchJson').mockResolvedValue({ data: { items: Array(safe.SOURCE_LIMITS.elements + 1).fill('x') }, bytes: 1 }) await expect(fetchEvobgpPages('https://bgp.test', 'secret', '/v1/communities', 200)).rejects.toThrow('element limit') fetcher.mockResolvedValue({ data: { items: [], has_more: true, next_cursor: 'same' }, bytes: 1 }) await expect(fetchEvobgpPages('https://bgp.test', 'secret', '/v1/communities', 200)).rejects.toThrow('repeated') fetcher.mockResolvedValue({ data: { items: [], has_more: true }, bytes: 1 }) await expect(fetchEvobgpPages('https://bgp.test', 'secret', '/v1/communities', 200)).rejects.toThrow('Incomplete') fetcher.mockResolvedValue({ data: { items: [], has_more: 'yes' }, bytes: 1 }) await expect(fetchEvobgpPages('https://bgp.test', 'secret', '/v1/communities', 200)).rejects.toThrow('has_more') }) it('bounds cumulative snapshot bytes, page count and elapsed pagination time', async () => { let page = 0 const fetcher = vi.spyOn(safe, 'safeFetchJson').mockImplementation(async () => ({ data: { items: [], has_more: true, next_cursor: String(++page) }, bytes: safe.SOURCE_LIMITS.bytes })) await expect(fetchEvobgpPages('https://bgp.test', 'secret', '/v1/communities', 200)).rejects.toThrow('byte limit') expect(fetcher).toHaveBeenCalledTimes(4) page = 0; fetcher.mockClear().mockImplementation(async () => ({ data: { items: [], has_more: true, next_cursor: String(++page) }, bytes: 1 })) await expect(fetchEvobgpPages('https://bgp.test', 'secret', '/v1/communities', 200)).rejects.toThrow('page limit') expect(fetcher).toHaveBeenCalledTimes(safe.SOURCE_LIMITS.pages) let now = 1000 vi.spyOn(Date, 'now').mockImplementation(() => now) fetcher.mockClear().mockImplementation(async () => { now += safe.SOURCE_LIMITS.timeoutMs + 1; return { data: { items: [], has_more: true, next_cursor: 'later' }, bytes: 1 } }) await expect(fetchEvobgpPages('https://bgp.test', 'secret', '/v1/communities', 200)).rejects.toThrow('deadline') expect(fetcher).toHaveBeenCalledTimes(1) }) it('rejects excessive JSON URL entries without replacing last good', async () => { const { db } = memory(); feed(db, 'f') vi.spyOn(safe, 'safeFetchJson').mockResolvedValue({ data: Array(safe.SOURCE_LIMITS.elements + 1).fill('1.1.1.1'), bytes: 1 }) await refreshIpList(db, 'f') expect(repos.getIpList(db, 'f')!.lastError).toContain('element limit') expect(repos.listIpListEntries(db, 'f')[0]!.cidr).toBe('8.8.8.8/32') }) })