/** * SSRF DNS Pinning 测试(v0.7.3 P2-1 → v0.7.5 扩充) * * 锁定单元: * D1 createPinnedLookup —— 只返回校验阶段锁定的 IP 集合(过滤非法 family), * 空集合返回 ENOTFOUND(防御)。 * D2 resolveRedirectTarget —— 重定向状态识别 + 相对/绝对/协议相对 Location 解析 + * 非法/缺失 Location 返回 null。 * D3 resolvePinnedIps —— IP 直连与私网拒绝(走 ssrf-guard 单一事实来源)。 * D4 ssrfPinnedFetch —— 代理激活退化 / 外部信号中止 / 超时转译 ETIMEDOUT / * 正常路径走 pinned undici Agent。 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; vi.mock('electron-log', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); // ===== DNS 表驱动(resolvePublicAddresses 依赖)===== const dnsTable: Record> = { 'public.example.com': [{ address: '93.184.216.34', 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]; }), })); // ===== 网络代理状态可控 mock(注意相对路径从 __tests__ 到 electron/utils)===== const proxyMock = vi.hoisted(() => ({ isProxyActive: vi.fn(() => false) })); vi.mock('../../../../utils/network-proxy', () => ({ isProxyActive: proxyMock.isProxyActive, })); // ===== undici 可控 mock(Agent + fetch)===== const undiciMock = vi.hoisted(() => { const agentInstances: Array<{ closed: boolean; options: unknown }> = []; class FakeAgent { closed = false; constructor(public options: unknown) { agentInstances.push(this); } async close(): Promise { this.closed = true; } } const fetch = vi.fn(); return { agentInstances, FakeAgent, fetch }; }); vi.mock('undici', () => ({ Agent: undiciMock.FakeAgent, fetch: undiciMock.fetch, })); import { createPinnedLookup, resolveRedirectTarget, resolvePinnedIps, ssrfPinnedFetch, } from '../ssrf-dispatcher'; import type { LookupCallback } from '../ssrf-dispatcher'; describe('createPinnedLookup', () => { function runLookup(lookup: (h: string, o: unknown, cb: LookupCallback) => void, host = 'h') { return new Promise<{ address: string; family: number }[]>((resolve, reject) => { const cb: LookupCallback = (err, addresses) => (err ? reject(err) : resolve(addresses!)); lookup(host, {}, cb); }); } it('D1: 仅返回钉死的 IP 集合(忽略 hostname),family 正确标注', async () => { const lookup = createPinnedLookup(['93.184.216.34', '2606:2800:220:1:248:1893:25c8:1946']); const result = await runLookup(lookup, 'attacker.example'); expect(result).toHaveLength(2); expect(result[0]).toEqual({ address: '93.184.216.34', family: 4 }); expect(result[1].family).toBe(6); }); it('D1: 非法 family(非 IPv4/IPv6 字符串)被过滤', async () => { const lookup = createPinnedLookup(['not-an-ip']); await expect(runLookup(lookup)).rejects.toMatchObject({ code: 'ENOTFOUND' }); }); it('D1: 空集合 → ENOTFOUND(防御:调用方不应构造空 pin dispatcher)', async () => { const lookup = createPinnedLookup([]); await expect(runLookup(lookup)).rejects.toMatchObject({ code: 'ENOTFOUND' }); }); it('D1: 混合合法 IP 与非法字符串 → 仅返回合法 IP', async () => { const lookup = createPinnedLookup(['8.8.8.8', 'garbage', '::1']); const result = await runLookup(lookup); expect(result).toEqual([ { address: '8.8.8.8', family: 4 }, { address: '::1', family: 6 }, ]); }); it('D1: hostname 参数完全被忽略(无论传什么都返回 pin 集合)', async () => { const lookup = createPinnedLookup(['1.1.1.1']); const result = await runLookup(lookup, 'evil-hostname.example'); expect(result).toEqual([{ address: '1.1.1.1', family: 4 }]); }); }); describe('resolveRedirectTarget', () => { const makeResponse = (status: number, location?: string) => ({ status, headers: { get: (name: string) => (name.toLowerCase() === 'location' ? (location ?? null) : null), }, }); it('D2: 301/302/303/307/308 识别并解析绝对 Location', () => { for (const status of [301, 302, 303, 307, 308]) { expect( resolveRedirectTarget(makeResponse(status, 'https://cdn.example.com/x'), 'https://a.test/'), ).toBe('https://cdn.example.com/x'); } }); it('D2: 相对 Location 以当前 URL 为基解析(RFC 7231)', () => { expect(resolveRedirectTarget(makeResponse(302, '/next?a=1'), 'https://a.test/dir/page')).toBe( 'https://a.test/next?a=1', ); }); it('D2: 相对 Location 不带前导斜杠 → 基于目录解析', () => { expect(resolveRedirectTarget(makeResponse(302, 'next'), 'https://a.test/dir/page')).toBe( 'https://a.test/dir/next', ); }); it('D2: 协议相对 Location(//host/path)→ 沿用当前协议', () => { expect(resolveRedirectTarget(makeResponse(302, '//cdn.example.com/x'), 'https://a.test/')).toBe( 'https://cdn.example.com/x', ); }); it('D2: 带 fragment 的 Location 解析', () => { expect(resolveRedirectTarget(makeResponse(301, '/new#section'), 'https://a.test/old')).toBe( 'https://a.test/new#section', ); }); it('D2: 非 3xx 状态 → null(终态)', () => { expect(resolveRedirectTarget(makeResponse(200), 'https://a.test/')).toBeNull(); expect(resolveRedirectTarget(makeResponse(404), 'https://a.test/')).toBeNull(); expect(resolveRedirectTarget(makeResponse(300), 'https://a.test/')).toBeNull(); // 300 不在集合 expect(resolveRedirectTarget(makeResponse(304), 'https://a.test/')).toBeNull(); // 304 不在集合 }); it('D2: 缺失/非法 Location → null', () => { expect(resolveRedirectTarget(makeResponse(302), 'https://a.test/')).toBeNull(); expect(resolveRedirectTarget(makeResponse(302, ''), 'https://a.test/')).toBeNull(); expect(resolveRedirectTarget(makeResponse(302, 'http://[::bad'), 'https://a.test/')).toBeNull(); }); it('D2: Location 为纯路径但当前 URL 含 query → 解析后不丢 query', () => { expect(resolveRedirectTarget(makeResponse(303, '/landing'), 'https://a.test/p?x=1')).toBe( 'https://a.test/landing', ); }); }); describe('resolvePinnedIps', () => { it('D3: IP 直连 URL —— 公网 IP 直接返回', async () => { const ips = await resolvePinnedIps('https://93.184.216.34/x'); expect(ips).toEqual(['93.184.216.34']); }); it('D3: 私有/回环 IP 直连被拒(单一事实来源 ssrf-guard)', async () => { for (const host of ['127.0.0.1', '10.0.0.5', '169.254.169.254', '192.168.1.1', '[::1]']) { await expect(resolvePinnedIps(`http://${host}/latest`)).rejects.toThrow(/Blocked SSRF/); } }); it('D3: 非 http/https 协议被拒', async () => { await expect(resolvePinnedIps('ftp://example.com')).rejects.toThrow(/not allowed/); }); it('D3: 非法 URL 被拒', async () => { await expect(resolvePinnedIps('not a url')).rejects.toThrow(/Invalid URL/); }); it('D3: 公网域名解析返回 pin 集合', async () => { const ips = await resolvePinnedIps('http://public.example.com/page'); expect(ips).toEqual(['93.184.216.34']); }); it('D3: 无 DNS 记录 → 拒绝', async () => { await expect(resolvePinnedIps('http://nx.example.com/')).rejects.toThrow(/no DNS records/); }); }); describe('ssrfPinnedFetch — 代理退化 / 超时转译 / 用后即毁', () => { beforeEach(() => { proxyMock.isProxyActive.mockReturnValue(false); undiciMock.fetch.mockReset(); undiciMock.agentInstances.length = 0; // Agent 实例列表按测试清零 }); afterEach(() => { vi.unstubAllGlobals(); proxyMock.isProxyActive.mockReset(); }); it('D4: 代理激活 → 退化为普通 fetch(走全局 fetchWithTimeout,不构造 pinned Agent)', async () => { proxyMock.isProxyActive.mockReturnValue(true); const glob = vi.fn(async () => new Response('via-proxy', { status: 200 })); vi.stubGlobal('fetch', glob); const resp = await ssrfPinnedFetch('http://public.example.com/', { method: 'GET' }, 1000); expect(resp.status).toBe(200); expect(glob).toHaveBeenCalledTimes(1); expect(undiciMock.fetch).not.toHaveBeenCalled(); // 代理路径无 Agent 实例(无 pin 集合泄漏) expect(undiciMock.agentInstances.length).toBe(0); }); it('D4: 外部信号已中止 → 直接抛 AbortError(不发起请求)', async () => { const controller = new AbortController(); controller.abort(); await expect( ssrfPinnedFetch('http://public.example.com/', {}, 1000, controller.signal), ).rejects.toMatchObject({ name: 'AbortError' }); expect(undiciMock.fetch).not.toHaveBeenCalled(); }); it('D4: 超时 → 转译为 ETIMEDOUT 错误', async () => { undiciMock.fetch.mockImplementation( (_url: string, init: { signal?: AbortSignal }) => new Promise((_resolve, reject) => { init?.signal?.addEventListener('abort', () => { reject(new Error('aborted by timeout')); }); }), ); await expect(ssrfPinnedFetch('http://public.example.com/', {}, 30)).rejects.toMatchObject({ code: 'ETIMEDOUT', message: expect.stringContaining('timed out after 30ms'), }); }); it('D4: 正常路径使用 pinned undici Agent(构造一次)且响应透传', async () => { undiciMock.fetch.mockResolvedValue(new Response('pinned-ok', { status: 200 })); const resp = await ssrfPinnedFetch('http://public.example.com/', { method: 'GET' }, 1000); expect(resp.status).toBe(200); expect(undiciMock.fetch).toHaveBeenCalledTimes(1); expect(undiciMock.agentInstances.length).toBe(1); }); it('D4: pinned Agent 的 connect.lookup 返回校验 IP 集合(pinning 契约)', async () => { undiciMock.fetch.mockResolvedValue(new Response('ok', { status: 200 })); await ssrfPinnedFetch('http://public.example.com/', {}, 1000); const agent = undiciMock.agentInstances[undiciMock.agentInstances.length - 1]; const connect = ( agent.options as { connect: { lookup: (h: string, o: unknown, cb: LookupCallback) => void } } ).connect; expect(typeof connect.lookup).toBe('function'); const addresses = await new Promise((resolve, reject) => { connect.lookup('anything.example', {}, (err, addrs) => (err ? reject(err) : resolve(addrs))); }); expect(addresses).toEqual([{ address: '93.184.216.34', family: 4 }]); }); it('D4: 每次请求构造一次性 Agent,请求结束后关闭(用后即毁)', async () => { undiciMock.fetch.mockResolvedValue(new Response('ok', { status: 200 })); await ssrfPinnedFetch('http://public.example.com/', {}, 1000); await ssrfPinnedFetch('http://public.example.com/', {}, 1000); const agents = undiciMock.agentInstances.slice(-2); expect(agents.every((a) => a.closed)).toBe(true); expect(agents.length).toBe(2); // 两个请求各自独立 Agent,不跨请求复用 }); it('D4: 私有 IP 目标在校验阶段被拒(不构造 Agent、不发请求)', async () => { await expect(ssrfPinnedFetch('http://127.0.0.1:9999/x', {}, 1000)).rejects.toThrow( /Blocked SSRF/, ); expect(undiciMock.fetch).not.toHaveBeenCalled(); }); });