/** * SSRF DNS Pinning Dispatcher(v0.7.3 P2-1) * * 关闭 M7 审查确认的 DNS rebinding 窗口:此前 validateSSRF 在校验阶段解析一次 * DNS,fetch 实际连接时 undici 再次解析 —— 两次解析之间攻击者可切换 DNS 记录 * (TTL=0)把连接导向内网。原注释断言"Node fetch 下无法彻底关闭",该结论只对 * 全局 fetch 成立;主进程已依赖 undici(network-proxy 的 setGlobalDispatcher), * undici 的 Agent 支持 connect.lookup 自定义 —— 校验通过的 IP 集合可精确 pin 到 * 连接层,TLS SNI/证书校验仍基于原始域名(undici 将 servername 保持为 hostname)。 * * 契约: * - resolvePublicAddresses(ssrf-guard)是校验与地址解析的唯一事实来源, * 本模块 pin 的就是它返回的那批 IP —— 校验与连接同源,无双解析窗口; * - 代理激活(network-proxy.isProxyActive())时 pinning 不可实现(DNS 在代理端 * 解析)且 per-request dispatcher 会旁路用户代理 —— 退化为"仅入口校验", * 走全局 dispatcher(保持既有语义与代理兼容); * - fetchWithTimeoutPinned 合并外部 abort signal 与超时控制,语义对齐 * BaseAdapter.fetchWithTimeout(超时 → ETIMEDOUT 可重试;外部中断原样抛出); * - 每次 pinned 请求构造一次性 Agent 并在 finally 中 close(连接池即用即毁, * 防止把"上一请求的 pin 集合"泄漏给后续请求)。 */ import { Agent, fetch as undiciFetch } from 'undici'; import { isIP } from 'node:net'; import { resolvePublicAddresses } from './ssrf-guard'; import { fetchWithTimeout } from './network-utils'; import { isProxyActive } from '../../../utils/network-proxy'; import log from 'electron-log'; /** 标准 dns.lookup 回调签名(undici connect.lookup 消费) */ export type LookupCallback = ( err: NodeJS.ErrnoException | null, addresses?: Array<{ address: string; family: number }>, ) => void; /** undici connect.lookup 的函数签名形态 */ export type PinnedLookup = (hostname: string, options: unknown, callback: LookupCallback) => void; /** * 构造"钉死 IP 集合"的 lookup 函数:无论传入什么 hostname,都只返回校验阶段 * 锁定的公网地址(过滤非法 family)。集合为空时返回 ENOTFOUND(防御性—— * 调用方在集合为空时不应构造 dispatcher)。 */ export function createPinnedLookup(allowedIps: string[]): PinnedLookup { return (_hostname, _options, callback) => { process.nextTick(() => { const addresses = allowedIps .map((ip) => ({ address: ip, family: isIP(ip) })) .filter((a): a is { address: string; family: number } => a.family === 4 || a.family === 6); if (addresses.length === 0) { const err: NodeJS.ErrnoException = new Error('pinned lookup: no allowed addresses'); err.code = 'ENOTFOUND'; callback(err, undefined); return; } callback(null, addresses); }); }; } /** * 校验 URL 并返回 pinning 用的公网 IP 集合。 * 校验失败原样抛出(调用方按 SSRF 阻断处理)。 */ export async function resolvePinnedIps(url: string): Promise { return resolvePublicAddresses(url); } /** * 带 SSRF pinning 的 fetch(http_request / web_fetch Phase1 / 可达性预检共用)。 * * 行为: * 1. 代理激活 → 退化为普通 fetchWithTimeout(仅入口校验语义,见模块注释); * 2. 否则 → 解析并校验公网 IP → 一次性 undici Agent(pinned lookup)发起请求; * 3. 超时/外部中断语义与 fetchWithTimeout 对齐; * 4. 返回 Response 与全局 fetch 兼容(status/ok/headers/text/url/body)。 * * @param url 目标 URL(调用方已保证 http/https;本函数再做一次全量 SSRF 校验) * @param init RequestInit(redirect 等由调用方决定) * @param timeoutMs 请求超时 * @param externalSignal 外部 abort 信号(引擎中断透传,可选) */ export async function ssrfPinnedFetch( url: string, init: RequestInit, timeoutMs: number, externalSignal?: AbortSignal, ): Promise { const ips = await resolvePublicAddresses(url); // 代理激活:DNS 在代理端解析,pinning 不可实现;走全局 dispatcher 保持代理语义 if (isProxyActive()) { return fetchWithTimeout(url, init, timeoutMs); } // 外部信号已中止 → 直接抛 AbortError(对齐 fetchWithTimeout 行为) if (externalSignal?.aborted) { const err = new Error('Aborted'); err.name = 'AbortError'; throw err; } const controller = new AbortController(); let timedOut = false; const timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs); const onExternalAbort = () => controller.abort(); if (externalSignal) { externalSignal.addEventListener('abort', onExternalAbort, { once: true }); } // 一次性 pinned Agent(connect 超时对齐 network-proxy 的 15s 连接上限) const dispatcher = new Agent({ connect: { timeout: 15_000, lookup: createPinnedLookup(ips) as never }, }); try { const response = await undiciFetch(url, { ...(init as Record), signal: controller.signal, dispatcher, } as never); return response as unknown as Response; } catch (err) { const externalAborted = externalSignal?.aborted === true; if (timedOut && !externalAborted) { const timeoutError = new Error( `Request timed out after ${timeoutMs}ms (url=${String(url).slice(0, 120)})`, ); (timeoutError as Error & { code: string }).code = 'ETIMEDOUT'; throw timeoutError; } throw err; } finally { clearTimeout(timer); if (externalSignal) { externalSignal.removeEventListener('abort', onExternalAbort); } // 一次性 dispatcher 用后即毁(连接池不跨请求复用,防止 pin 集合泄漏) void dispatcher.close().catch((closeErr) => { log.debug(`[SSRFDispatcher] dispatcher close failed: ${(closeErr as Error).message}`); }); } } const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]); /** * 解析重定向目标(纯函数,表测锁定)。 * * @returns 下一跳绝对 URL;非重定向状态/缺失/非法 Location 返回 null * (表示当前响应即终态或无法跟随,由调用方按既有语义处理)。 * 相对 Location 以 currentUrl 为基解析(RFC 7231)。 */ export function resolveRedirectTarget( response: { status: number; headers: { get(name: string): string | null } }, currentUrl: string, ): string | null { if (!REDIRECT_STATUS.has(response.status)) return null; const location = response.headers.get('location'); if (!location) return null; try { return new URL(location, currentUrl).toString(); } catch { return null; } }