Files
EvoFirewall/apps/api/src/services/source-hardening.test.ts
T
Denozordec 9fa6269174
quality / changes (push) Successful in 7s
quality / commitlint (push) Skipped
quality / openapi (push) Successful in 30s
quality / web (push) Successful in 1m3s
quality / api (push) Successful in 1m15s
quality / linux-agent-smoke (push) Failing after 27s
quality / docker-check (push) Failing after 2m28s
CD / quality (push) Failing after 5m52s
CD / publish (push) Skipped
feat: harden firewall policies telemetry and operations
Implement reliable agent apply, telemetry v2, analytics retention, guarded rollouts, enrollment, labels and webhooks. Improve ReUI frontend data safety and bounded list loading, and add Docker smoke, recovery and resource checks. Document local verification and pending native acceptance.
2026-10-26 13:10:58 +07:00

176 lines
12 KiB
TypeScript

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<typeof createMemoryDb>[] = []
beforeAll(async () => {
await new Promise<void>(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<void>(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<typeof memory>['db'], id: string, config: Record<string, unknown> = {}) {
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<unknown>((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')
})
})