/** * Pinned CONNECT Proxy — Agent 浏览器通道的 SSRF 根治层(v0.8.0 P1-4) * * 背景:web_fetch 的浏览器回退通道与 web_browser 走 Chromium 自有网络栈, * 此前只有工具入口一次 SSRF 校验 —— Chromium 自己解析 DNS,入口校验通过到 * 实际连接之间若攻击者切换 DNS(TTL=0),可借隐藏浏览器触达内网(DNS * rebinding 残余窗口,比 HTTP 通道的 DNS pinning 防线弱一档)。 * * 根治方案:主进程启动本地 HTTP 代理(127.0.0.1 随机端口),Agent 浏览器 * 分区(persist:metona-agent-browser)的全部流量经此代理: * - CONNECT(https):解析目标 host → resolvePublicAddresses 校验(与工具 * 入口同策略:仅公网)→ **直连校验阶段锁定的 IP** 建立隧道。TLS/SNI/证书 * 校验仍由 Chromium 端到端完成(隧道内字节透传,不做 MITM),既闭 * rebinding 又不破坏 HTTPS。 * - 绝对形式 HTTP:同校验后按 pinned IP 转发。 * - 校验失败 → 403 拒绝 + WARN 日志(fail-closed)。 * * 边界(显式记录): * - 用户已配置 network.proxyUrl(isProxyActive())时,调用方不启用本代理, * 保持用户代理语义 —— 代理模式下 DNS 在代理端解析,pinning 不可实现 * (与 M7 结论一致,该场景的已知限制维持原文档口径)。 * - 代理启动失败时降级为"无 pinned 代理"(可用性优先),仅 WARN,不阻断 * 浏览器工具。 */ import * as http from 'http'; import * as net from 'net'; import log from 'electron-log'; import { resolvePublicAddresses } from '../harness/tools/built-in/ssrf-guard'; /** 校验函数签名(可注入以便测试;默认为生产实现 resolvePublicAddresses) */ export type AddressResolver = (url: string) => Promise; export interface PinnedProxy { /** 本地监听端口(127.0.0.1) */ port: number; /** 停止代理服务器 */ close: () => Promise; } /** 解析 "host:port" 形态(CONNECT 请求行目标) */ function splitHostPort(target: string): { host: string; port: number } | null { const lastColon = target.lastIndexOf(':'); if (lastColon <= 0) return null; let host = target.slice(0, lastColon); const portStr = target.slice(lastColon + 1); // IPv6 字面量目标:去方括号(resolvePublicAddresses 侧已处理 [host] 形态) if (host.startsWith('[') && host.endsWith(']')) { host = host.slice(1, -1); } const port = Number(portStr); if (!Number.isInteger(port) || port <= 0 || port > 65535) return null; return { host, port }; } /** 400/403 之类的简易响应后断开 */ function rejectRequest(socket: net.Socket, status: string, body: string): void { if (socket.writable) { socket.write( `HTTP/1.1 ${status}\r\nConnection: close\r\nContent-Length: ${body.length}\r\n\r\n${body}`, ); } socket.destroy(); } /** * 创建 Pinned 代理服务器(工厂形式,测试可注入 resolver)。 * * @param resolve 地址校验/解析实现(默认 resolvePublicAddresses —— 仅公网放行) */ export function createPinnedProxy( resolve: AddressResolver = resolvePublicAddresses, ): Promise { const server = http.createServer((req, res) => { // 绝对形式 HTTP(Chromium 经代理的明文请求):同校验后按 pinned IP 转发 void handlePlainHttp(req, res, resolve); }); server.on('connect', (req, clientSocket, head) => { void handleConnect(req, clientSocket as net.Socket, head, resolve); }); server.on('clientError', (_err, socket) => { try { socket.destroy(); } catch { /* ignore */ } }); return new Promise((resolvePromise, rejectPromise) => { const onError = (err: Error): void => { server.removeListener('listening', onListening); rejectPromise(err); }; const onListening = (): void => { server.removeListener('error', onError); const addr = server.address(); const port = typeof addr === 'object' && addr ? addr.port : 0; resolvePromise({ port, close: () => new Promise((res) => { server.close(() => res()); }), }); }; server.once('error', onError); server.once('listening', onListening); server.listen(0, '127.0.0.1'); }); } /** CONNECT 隧道:校验目标 → 直连 pinned IP → 双向管道 */ async function handleConnect( req: http.IncomingMessage, clientSocket: net.Socket, head: Buffer, resolve: AddressResolver, ): Promise { const target = req.url ?? ''; const parsed = splitHostPort(target); if (!parsed) { rejectRequest(clientSocket, '400 Bad Request', 'Invalid CONNECT target'); return; } const { host, port } = parsed; let ips: string[]; try { // 以 http(s) 形式构造 URL 供 resolvePublicAddresses 校验(其只关心协议与 host; // 443 用 https 仅为语义准确,校验逻辑对两者一致) const scheme = port === 443 ? 'https' : 'http'; ips = await resolve(`${scheme}://${host}:${port}`); } catch (err) { log.warn(`[PinnedProxy] CONNECT ${host}:${port} blocked: ${(err as Error).message}`); rejectRequest(clientSocket, '403 Forbidden', 'SSRF validation failed'); return; } const upstream = net.connect({ host: ips[0] ?? host, port }); let established = false; upstream.once('connect', () => { established = true; if (clientSocket.writable) { clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); if (head && head.length > 0) upstream.write(head); clientSocket.pipe(upstream); upstream.pipe(clientSocket); } else { upstream.destroy(); } }); const cleanup = (): void => { try { clientSocket.destroy(); } catch { /* ignore */ } try { upstream.destroy(); } catch { /* ignore */ } }; clientSocket.once('error', cleanup); clientSocket.once('close', () => { if (established) cleanup(); }); upstream.once('error', (err) => { log.warn(`[PinnedProxy] upstream error for ${host}:${port}: ${(err as Error).message}`); if (!established) { rejectRequest(clientSocket, '502 Bad Gateway', 'Upstream connect failed'); } else { cleanup(); } }); } /** 绝对形式 HTTP 转发:校验目标 → 按 pinned IP 发起请求 → 回写响应 */ async function handlePlainHttp( req: http.IncomingMessage, res: http.ServerResponse, resolve: AddressResolver, ): Promise { const rawUrl = req.url ?? ''; let parsed: URL; try { parsed = new URL(rawUrl); } catch { res.writeHead(400).end('Invalid absolute-form URL'); return; } if (parsed.protocol !== 'http:') { // https 走 CONNECT,明文通道只处理 http 绝对形式 res.writeHead(400).end('Only http absolute-form supported (https uses CONNECT)'); return; } let ips: string[]; try { ips = await resolve(rawUrl); } catch (err) { log.warn(`[PinnedProxy] HTTP ${parsed.host} blocked: ${(err as Error).message}`); res.writeHead(403).end('SSRF validation failed'); return; } const port = parsed.port ? Number(parsed.port) : 80; const upstreamReq = http.request( { host: ips[0] ?? parsed.hostname, port, method: req.method, path: parsed.pathname + parsed.search, headers: { ...req.headers, host: parsed.host }, }, (upstreamRes) => { res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers); upstreamRes.pipe(res); }, ); upstreamReq.on('error', (err) => { log.warn(`[PinnedProxy] upstream http error for ${parsed.host}: ${(err as Error).message}`); if (!res.headersSent) res.writeHead(502); res.end('Upstream request failed'); }); req.pipe(upstreamReq); } // ===== 模块级单例(BrowserWindowManager / network-proxy 消费) ===== let singleton: PinnedProxy | null = null; let starting: Promise | null = null; /** 是否已有 Pinned 代理在运行(applySessionProxy 据此决定是否覆盖分区代理) */ export function isPinnedProxyActive(): boolean { return singleton !== null; } /** * 确保 Pinned 代理已启动,返回端口;失败返回 null(调用方降级为无 pinned 代理)。 * 幂等:并发调用共享同一次启动。 */ export async function ensurePinnedProxy(): Promise { if (singleton) return singleton.port; if (!starting) { starting = (async (): Promise => { try { singleton = await createPinnedProxy(); log.info(`[PinnedProxy] SSRF-pinned proxy listening on 127.0.0.1:${singleton.port}`); return singleton; } catch (err) { log.warn( '[PinnedProxy] failed to start pinned proxy (browser channel falls back to unpinned):', err, ); singleton = null; return null; } finally { starting = null; } })(); } const result = await starting; return result?.port ?? null; }