/** * ssrf-guard 共享模块测试(v0.6.4 P2-2) * * 背景:SSRF 校验此前是 http_request 内部私有实现,web_fetch/浏览器回退完全无校验。 * 收敛到单一模块后,本文件以表格化用例锁定私有段判定与 DNS 解析行为; * 另验证 WebFetchTool 对内网 URL 在发出任何网络请求前即被拒绝, * 且不进入浏览器回退通道(否则等于借 Chromium 绕过)。 */ import { describe, it, expect, vi } from 'vitest'; vi.mock('electron-log', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); // DNS lookup 按域名返回表驱动结果(validateSSRF 内部使用 all: true) const dnsTable: Record> = { 'public.example.com': [{ address: '93.184.216.34', family: 4 }], 'mixed.example.com': [ { address: '93.184.216.34', family: 4 }, { address: '192.168.1.10', family: 4 }, { address: '2606:2800:220:1:248:1893:25c8:1946', family: 6 }, ], 'v4mapped.example.com': [{ address: '::ffff:127.0.0.1', family: 6 }], 'localhost': [{ address: '127.0.0.1', family: 4 }], 'nx.example.com': [], }; vi.mock('node:dns/promises', () => ({ lookup: vi.fn(async (hostname: string) => { if (!(hostname in dnsTable)) { throw Object.assign(new Error(`ENOTFOUND ${hostname}`), { code: 'ENOTFOUND' }); } return dnsTable[hostname]; }), })); import { isPrivateIP, validateSSRF } from '../ssrf-guard'; import { WebFetchTool } from '../web-fetch'; import type { ToolExecutionContext } from '../../../types/metona-tool'; describe('isPrivateIP 表格化判定', () => { const privateCases = [ '127.0.0.1', '127.9.9.9', // 整个 127/8 都是回环 '10.1.2.3', '192.168.0.1', '172.16.0.1', '172.31.255.255', '169.254.169.254', // 云元数据 '0.0.0.0', '224.0.0.5', // 组播 '240.0.0.1', // 保留 '::1', 'fe80::a', 'fc00::a', 'fd12::a', '::ffff:10.0.0.5', // v4 映射递归检测 ]; const publicCases = [ '8.8.8.8', '93.184.216.34', '172.32.0.1', // 刚好超出 172.16-31 '::ffff:8.8.8.8', '2606:2800:220:1:248:1893:25c8:1946', ]; it.each(privateCases)('%s → 私有(拒绝)', (ip) => { expect(isPrivateIP(ip)).toBe(true); }); it.each(publicCases)('%s → 公网(放行)', (ip) => { expect(isPrivateIP(ip)).toBe(false); }); }); describe('validateSSRF', () => { it('协议白名单:非 http(s) 直接拒绝', async () => { await expect(validateSSRF('ftp://example.com')).rejects.toThrow('Blocked SSRF'); await expect(validateSSRF('file:///etc/passwd')).rejects.toThrow('Blocked SSRF'); }); it('hostname 为 IP 时直接判定,不做 DNS', async () => { await expect(validateSSRF('http://127.0.0.1:8080/admin')).rejects.toThrow( 'private/loopback address', ); await expect(validateSSRF('http://169.254.169.254/latest/meta-data')).rejects.toThrow( 'private/loopback address', ); }); it('域名解析出任一私有 IP 即拒绝(防 rebinding 只查首个 IP)', async () => { await expect(validateSSRF('http://mixed.example.com/')).rejects.toThrow( /resolves to private IP/, ); }); it('::ffff: 映射的回环地址同样拒绝', async () => { await expect(validateSSRF('http://v4mapped.example.com/')).rejects.toThrow( /resolves to private IP/, ); }); it('纯公网域名正常通过', async () => { await expect(validateSSRF('http://public.example.com/page')).resolves.toBeUndefined(); }); it('DNS 解析为空(无记录)即拒绝(fail-closed)', async () => { await expect(validateSSRF('http://nx.example.com/')).rejects.toThrow('no DNS records'); }); it('DNS 查询异常(ENOTFOUND 等)同样拒绝', async () => { await expect(validateSSRF('http://not-in-table.invalid/')).rejects.toThrow( 'DNS resolution failed', ); }); }); describe('WebFetchTool — SSRF 入口拦截(v0.6.4 安全不对称根治)', () => { const context: ToolExecutionContext = { sessionId: 't', workspacePath: process.cwd(), iteration: 1, requestId: 'r', }; it('拒绝回环地址且不发起任何网络请求、不进入浏览器回退', async () => { const fetchSpy = vi.fn(); vi.stubGlobal('fetch', fetchSpy); const tool = new WebFetchTool(); const result = (await tool.execute({ url: 'http://127.0.0.1:4567/internal' }, context)) as { success?: boolean; error?: string; }; expect(result.success).toBe(false); expect(result.error ?? '').toContain('Blocked SSRF'); // 关键契约:零网络请求(HTTP 与浏览器两个通道都不允许触达内网) expect(fetchSpy).not.toHaveBeenCalled(); vi.unstubAllGlobals(); }); it('拒绝云元数据地址', async () => { const tool = new WebFetchTool(); const result = (await tool.execute({ url: 'http://169.254.169.254/latest/meta-data/' }, context)) as { success?: boolean; error?: string; }; expect(result.success).toBe(false); expect(result.error ?? '').toContain('Blocked SSRF'); }); it('拒绝解析为内网的域名(如 localhost)', async () => { const tool = new WebFetchTool(); const result = (await tool.execute({ url: 'http://localhost/api' }, context)) as { success?: boolean; error?: string; }; expect(result.success).toBe(false); expect(result.error ?? '').toContain('Blocked SSRF'); }); });