// tests/unit/main/utils/ssrf.test.ts // // 守 SSRF 防护的**范围判定**契约(2026-10-20,r201): // // isPrivateIpLiteral(host) : 同步,判字面 IP 是否落在禁止段 // isPrivateUrl(urlString) : 异步,判整个 URL 是否指向禁止地址 // // 这份文件是主进程唯一的出站地址白名单(当前 caller:window.ts 的 // setWindowOpenHandler 同源防御)。判错的方向是"把公网当内网拦掉" //(误伤)或"把内网当公网放行"(SSRF)—— 两个方向都要锁。 // // 覆盖的段位(源码 IPV4_PRIVATE_RANGES / IPV6_PRIVATE_RANGES 的语义): // IPv4 : 0/8 10/8 127/8 100.64/10 169.254/16 172.16/12 192.168/16 // 224/4(multicast) 240/4(reserved) // IPv6 : ::/128 ::1/128 fe80::/10 fc00::/7 ff00::/8 2002::/16(6to4) // 64:ff9b::/96(NAT64) // IPv4-mapped IPv6(::ffff:a.b.c.d)按**数值**识别(r85),不依赖文本前缀。 import { beforeEach, describe, expect, it, vi } from 'vitest'; const { lookupMock } = vi.hoisted(() => ({ lookupMock: vi.fn() })); vi.mock('dns', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, lookup: lookupMock }; }); import { isPrivateIpLiteral, isPrivateUrl } from '../../../../src/main/utils/ssrf'; /** 让 mock 的 dns.lookup 回调返回给定的地址列表 */ function resolveTo(...addresses: string[]): void { lookupMock.mockImplementation( (_host: string, _opts: unknown, cb: (err: unknown, addrs: unknown[]) => void) => { cb(null, addresses.map((address) => ({ address, family: address.includes(':') ? 6 : 4 }))); }, ); } describe('isPrivateIpLiteral — IPv4 段位(r201)', () => { it('RFC1918 / loopback / CGNAT / link-local / multicast / reserved 全拦', () => { for (const ip of [ '0.0.0.0', '10.0.0.1', '127.0.0.1', '127.1.2.3', '100.64.0.1', '169.254.1.1', '172.16.0.1', '172.31.255.255', '192.168.1.1', '224.0.0.1', '240.0.0.1', ]) { expect(isPrivateIpLiteral(ip), ip).toBe(true); } }); it('公网地址放行', () => { for (const ip of ['1.1.1.1', '8.8.8.8', '172.32.0.1', '172.15.255.255', '100.128.0.1', '223.255.255.255']) { expect(isPrivateIpLiteral(ip), ip).toBe(false); } }); it('非 IP 字面量(hostname)→ false,交由调用方进一步解析', () => { expect(isPrivateIpLiteral('example.com')).toBe(false); expect(isPrivateIpLiteral('localhost')).toBe(false); }); }); describe('isPrivateIpLiteral — IPv6 段位(r201)', () => { it('loopback / unspecified / link-local / ULA / multicast / 6to4 / NAT64 全拦', () => { for (const ip of [ '::', '::1', 'fe80::1', 'fc00::1', 'fd12:3456::1', 'ff02::1', '2002::1', '64:ff9b::1', ]) { expect(isPrivateIpLiteral(ip), ip).toBe(true); } }); it('公网 IPv6 放行', () => { expect(isPrivateIpLiteral('2606:4700:4700::1')).toBe(false); expect(isPrivateIpLiteral('2001:db8::1')).toBe(false); }); it('IPv4-mapped IPv6 按数值识别(r85):三种写法都拦', () => { expect(isPrivateIpLiteral('::ffff:127.0.0.1')).toBe(true); expect(isPrivateIpLiteral('::ffff:7f00:1')).toBe(true); expect(isPrivateIpLiteral('0:0:0:0:0:ffff:7f00:1')).toBe(true); }); it('IPv4-mapped 公网地址放行', () => { expect(isPrivateIpLiteral('::ffff:8.8.8.8')).toBe(false); }); }); describe('isPrivateUrl — URL 层(r201)', () => { beforeEach(() => { lookupMock.mockReset(); resolveTo('93.184.216.34'); }); it('非 http(s) 协议 → 拒', async () => { await expect(isPrivateUrl('file:///C:/Windows/System32')).resolves.toBe(true); await expect(isPrivateUrl('ftp://example.com/x')).resolves.toBe(true); }); it('非法 URL → 拒(不抛)', async () => { await expect(isPrivateUrl('not a url')).resolves.toBe(true); }); it('host 是私有 IP 字面量 → 拒(不走 DNS)', async () => { await expect(isPrivateUrl('http://127.0.0.1:8080/x')).resolves.toBe(true); await expect(isPrivateUrl('http://[::1]/x')).resolves.toBe(true); expect(lookupMock).not.toHaveBeenCalled(); }); it('host 是公网 IP 字面量 → 放行', async () => { await expect(isPrivateUrl('https://8.8.8.8/x')).resolves.toBe(false); }); it('hostname 解析到内网 IP → 拒(防"字面合法、解析到内网")', async () => { resolveTo('93.184.216.34', '10.0.0.5'); await expect(isPrivateUrl('https://sneaky.example.com/x')).resolves.toBe(true); }); it('hostname 全部解析到公网 → 放行', async () => { resolveTo('93.184.216.34'); await expect(isPrivateUrl('https://example.com/x')).resolves.toBe(false); }); it('DNS 解析失败 → 保守拒(不抛)', async () => { lookupMock.mockImplementation( (_host: string, _opts: unknown, cb: (err: unknown, addrs: unknown[]) => void) => { cb(new Error('ENOTFOUND'), []); }, ); await expect(isPrivateUrl('https://example.com/x')).resolves.toBe(true); }); it('IDN 域名:URL 解析器已转 punycode(纯 ASCII)→ 走 DNS 判定', async () => { // 源码注释说「拒绝非 ASCII hostname」,但 URL 的 hostname 一定是 // punycode 形式(ASCII)—— 该分支对 URL 入参实际不可达。此处锁住 // **真实行为**(punycode 放行),避免有人以为 IDN 会被拦而误改。 await expect(isPrivateUrl('https://例え.jp/x')).resolves.toBe(false); }); });