/** * ssrf-guard 共享模块测试(v0.6.4 P2-2 → v0.7.5 扩充) * * 本文件以表格化用例锁定私有段判定与 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 }], 'multi-public.example.com': [ { address: '1.1.1.1', family: 4 }, { address: '8.8.8.8', family: 4 }, ], 'only-v6.example.com': [{ address: '2606:2800:220:1:248:1893:25c8:1946', family: 6 }], '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, resolvePublicAddresses, safeValidateSSRF } from '../ssrf-guard'; import { WebFetchTool } from '../web-fetch'; import { WebBrowserTool } from '../browser'; 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); }); it.each([ ['127.0.0.1', true], ['10.255.255.255', true], ['11.0.0.1', false], // 超出 10/8 ['192.169.0.1', false], // 超出 192.168/16 ['192.168.255.255', true], ['172.15.255.255', false], // 172.16 之前 ['172.31.255.255', true], // 172.31 边界 ['172.32.0.0', false], // 172.31 之后 ['169.253.255.255', false], // 169.254 之前 ['169.255.0.1', false], // 169.254 之后 ['223.255.255.255', false], // 224 之前 ['224.0.0.0', true], ['255.255.255.255', true], // >= 224 ])('IPv4 边界值 %s → %j', (ip, expected) => { expect(isPrivateIP(ip)).toBe(expected); }); it.each([ ['::ffff:169.254.169.254', true], // 映射云元数据 ['::ffff:192.168.1.1', true], // 映射私网 ['::ffff:93.184.216.34', false], // 映射公网 ['2001:4860:4860::8888', false], // 公网 IPv6 ])('IPv6 变体 %s → %j', (ip, expected) => { expect(isPrivateIP(ip)).toBe(expected); }); it(':: 未指定地址 → 非私有(实况契约:isPrivateIP 只覆盖 ::1/fe80/fc-fd/::ffff 映射)', () => { expect(isPrivateIP('::')).toBe(false); }); it('非 IP 字符串(域名)→ false(由调用方 DNS 判定)', () => { expect(isPrivateIP('example.com')).toBe(false); expect(isPrivateIP('')).toBe(false); }); }); describe('resolvePublicAddresses / 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'); await expect(validateSSRF('ws://example.com')).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('IPv6 字面量带方括号进入域名解析路径(Node URL.hostname 含 [])→ DNS 失败拒绝', async () => { // 实况契约:new URL('http://[::1]/').hostname === '[::1]',isIP 返回 0, // 落入域名分支 → DNS 解析失败(fail-closed 仍拒绝,只是错误信息不同) await expect(validateSSRF('http://[::1]/')).rejects.toThrow('DNS resolution failed'); }); 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', ); }); it('非法 URL 抛 Invalid URL', async () => { await expect(validateSSRF('not a url')).rejects.toThrow('Invalid URL'); }); it('多公网 IP 域名全部返回(resolvePublicAddresses 契约)', async () => { const ips = await resolvePublicAddresses('http://multi-public.example.com/'); expect(ips).toEqual(['1.1.1.1', '8.8.8.8']); }); it('纯 IPv6 公网域名返回 IPv6 地址', async () => { const ips = await resolvePublicAddresses('http://only-v6.example.com/'); expect(ips).toEqual(['2606:2800:220:1:248:1893:25c8:1946']); }); it('公网 IP 直连 URL 返回该 IP', async () => { expect(await resolvePublicAddresses('https://93.184.216.34/x')).toEqual(['93.184.216.34']); }); it('safeValidateSSRF 不抛错包装:私有返回 { ok:false }', async () => { const r = await safeValidateSSRF('http://127.0.0.1:8080'); expect(r.ok).toBe(false); if (!r.ok) expect(r.error).toContain('Blocked SSRF'); }); it('safeValidateSSRF 不抛错包装:公网返回 { ok:true }', async () => { const r = await safeValidateSSRF('http://public.example.com/'); expect(r).toEqual({ ok: true }); }); it('safeValidateSSRF 对非法协议返回 ok:false(不抛出)', async () => { const r = await safeValidateSSRF('file:///etc/passwd'); expect(r.ok).toBe(false); }); }); 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'); }); it('拒绝协议白名单之外的 URL(返回 URL must start with)', async () => { const tool = new WebFetchTool(); const result = (await tool.execute({ url: 'file:///etc/passwd' }, context)) as { success?: boolean; error?: string; }; expect(result.success).toBe(false); expect(result.error ?? '').toContain('URL must start with'); }); it('缺 url 参数同样返回协议校验错误', async () => { const tool = new WebFetchTool(); const result = (await tool.execute({}, context)) as { success?: boolean }; expect(result.success).toBe(false); }); }); describe('WebBrowserTool — open 动作 SSRF 入口拦截(v0.7.2 A2)', () => { const context: ToolExecutionContext = { sessionId: 't', workspacePath: process.cwd(), iteration: 1, requestId: 'r', }; it('拒绝回环地址且不创建任何浏览器窗口', async () => { const tool = new WebBrowserTool(); const result = (await tool.execute( { action: 'open', url: 'http://127.0.0.1:9222/devtools' }, context, )) as { success?: boolean; action?: string; error?: string }; expect(result.success).toBe(false); expect(result.action).toBe('open'); expect(result.error ?? '').toContain('Blocked SSRF'); }); it('拒绝云元数据地址', async () => { const tool = new WebBrowserTool(); const result = (await tool.execute( { action: 'open', 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 WebBrowserTool(); const result = (await tool.execute( { action: 'open', url: 'http://localhost/admin' }, context, )) as { success?: boolean; error?: string }; expect(result.success).toBe(false); expect(result.error ?? '').toContain('Blocked SSRF'); }); it('拒绝内网 IP 段(192.168/10/172.16-31)', async () => { const tool = new WebBrowserTool(); for (const url of ['http://192.168.1.1/', 'http://10.0.0.2/', 'http://172.20.0.5/']) { const result = (await tool.execute({ action: 'open', url }, context)) as { success?: boolean; error?: string; }; expect(result.success).toBe(false); expect(result.error ?? '').toContain('Blocked SSRF'); } }); it('非法协议仍走原有协议白名单拒绝(错误信息不变)', async () => { const tool = new WebBrowserTool(); const result = (await tool.execute({ action: 'open', url: 'file:///etc/passwd' }, context)) as { success?: boolean; error?: string; }; expect(result.success).toBe(false); expect(result.error ?? '').toContain('URL must start with'); }); it('缺 action → 报错', async () => { const tool = new WebBrowserTool(); const result = (await tool.execute({}, context)) as { success?: boolean; error?: string }; expect(result.success).toBe(false); expect(result.error).toContain('action'); }); it('unknown action → 报错', async () => { const tool = new WebBrowserTool(); const result = (await tool.execute({ action: 'frobnicate' }, context)) as { success?: boolean; error?: string; }; expect(result.success).toBe(false); expect(result.error).toContain('Unknown action'); }); });