/** * HTTP 请求工具(1 个) * * http_request — 发送 HTTP/REST API 请求 * * 使用 Node.js 18+ 内置 fetch API。 * 响应体截断到 50KB 防止结果过大。 * * #10 修复: SSRF 防护 — 解析 URL 域名并校验 IP,拒绝内网/回环/元数据地址。 */ import { lookup } from 'node:dns/promises'; import { isIP } from 'node:net'; import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; import type { MetonaToolDef } from '../../../harness/types'; import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; const ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'] as const; const MAX_BODY_BYTES = 50 * 1024; // 50KB /** * #10 修复: 检查 IP 是否为私有/内网/回环/元数据地址 * * 覆盖: * - IPv4: 127.0.0.0/8 (回环)、10.0.0.0/8、192.168.0.0/16、172.16.0.0/12、 * 169.254.0.0/16 (链路本地,含云元数据 169.254.169.254)、0.0.0.0/8、 * 224.0.0.0/4 (组播)、240.0.0.0/4 (保留) * - IPv6: ::1 (回环)、fe80::/10 (链路本地)、fc00::/7 (唯一本地)、::ffff: 映射的 IPv4 */ function isPrivateIP(ip: string): boolean { // IPv4 直接检测 if (isIP(ip) === 4) { const parts = ip.split('.').map(Number); if (parts[0] === 127) return true; // 回环 if (parts[0] === 10) return true; // 内网 if (parts[0] === 192 && parts[1] === 168) return true; // 内网 if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; // 内网 if (parts[0] === 169 && parts[1] === 254) return true; // 链路本地(含云元数据) if (parts[0] === 0) return true; // 0.0.0.0/8 if (parts[0] >= 224) return true; // 组播 + 保留 return false; } // IPv6 检测 if (isIP(ip) === 6) { const lower = ip.toLowerCase(); if (lower === '::1') return true; // 回环 if (lower.startsWith('fe80:')) return true; // 链路本地 if (lower.startsWith('fc') || lower.startsWith('fd')) return true; // 唯一本地 // ::ffff: 映射的 IPv4 — 提取 IPv4 部分递归检测 const v4MappedMatch = lower.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/); if (v4MappedMatch) return isPrivateIP(v4MappedMatch[1]); return false; } // 非 IP 格式(域名等),由调用方 DNS 解析后再检测 return false; } /** * #10 修复: SSRF 校验 — 解析 URL 域名并校验 IP * * 1. 协议白名单:仅允许 http/https * 2. DNS 解析域名,获取所有 IP 地址 * 3. 逐个检测 IP 是否为私有/内网/回环/元数据地址 * 4. 任意一个 IP 为私有即拒绝(防止 DNS rebinding 中只校验第一个 IP) * * 审查修复 (M7) — 已知限制:DNS rebinding 窗口 * --------------------------------------------------------------- * validateSSRF 在校验阶段 DNS 解析得到 IP,fetch 内部会再次 DNS 解析, * 两次解析之间存在 DNS rebinding 攻击窗口(攻击者可在校验通过后切换 * DNS 记录到内网 IP)。 * * 完全防护需要 "DNS pinning"(用校验通过的 IP 替换 URL hostname), * 但在 Node.js fetch 实现下不可行: * 1. HTTPS 请求时 fetch 会基于 URL hostname 校验证书 SAN, * 用 IP 替换会导致证书校验失败(除非目标证书 SAN 包含该 IP)。 * 2. Node fetch 将 Host 列为 forbidden header,无法通过设置 * Host header 保留原始域名。 * 3. fetch API 不暴露 SNI 自定义入口。 * * 当前实现的缓解措施: * - 校验所有 DNS 返回的 IP(防只校验第一个 IP 的绕过) * - redirect: 'manual' 禁用自动重定向(防重定向到内网) * - 窗口虽存在,但需要攻击者控制权威 DNS 并在毫秒级切换记录, * 实际利用难度较高。 * * 彻底防护建议:在 Electron 主进程层使用自定义 lookup 钩子实现 * DNS pinning(例如 undici 的 dispatcher.agent.connect lookup)。 * * @throws 如果 URL 指向私有/内网/回环地址 */ async function validateSSRF(url: string): Promise { let parsed: URL; try { parsed = new URL(url); } catch { throw new Error(`Invalid URL: ${url}`); } // 协议白名单 if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new Error(`Blocked SSRF: protocol "${parsed.protocol}" not allowed (only http/https)`); } const hostname = parsed.hostname; // 如果 hostname 本身就是 IP,直接检测 if (isIP(hostname)) { if (isPrivateIP(hostname)) { throw new Error(`Blocked SSRF: ${hostname} is a private/loopback address`); } return; } // 域名 — DNS 解析后检测所有 IP let addresses: Array<{ address: string }>; try { addresses = await lookup(hostname, { all: true }); } catch (err) { throw new Error(`Blocked SSRF: DNS resolution failed for ${hostname}: ${(err as Error).message}`); } if (addresses.length === 0) { throw new Error(`Blocked SSRF: no DNS records for ${hostname}`); } for (const { address } of addresses) { if (isPrivateIP(address)) { throw new Error(`Blocked SSRF: ${hostname} resolves to private IP ${address}`); } } } export class HttpRequestTool implements IMetonaTool { readonly definition: MetonaToolDef = { name: 'http_request', description: 'Send an HTTP/REST API request. Supports GET/POST/PUT/PATCH/DELETE/HEAD methods with custom headers and body. Response body is truncated to 50KB.', parameters: { type: 'object', properties: { url: { type: 'string', description: 'Request URL (must start with http:// or https://)' }, method: { type: 'string', description: 'HTTP method (default GET)', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'], }, headers: { type: 'object', description: 'Request headers as key-value pairs' }, body: { type: 'string', description: 'Request body (string)' }, timeout: { type: 'number', description: 'Timeout in milliseconds (default 30000, max 60000)' }, }, required: ['url'], }, category: MetonaToolCategory.NETWORK, riskLevel: MetonaRiskLevel.MEDIUM, requiresPermission: true, timeoutMs: 30_000, }; async execute(args: Record, _context: ToolExecutionContext): Promise { try { const url = args.url as string; const method = ((args.method as string) ?? 'GET').toUpperCase(); const headers = (args.headers as Record | undefined) ?? undefined; const body = args.body as string | undefined; const timeout = Math.min(60_000, Math.max(1, (args.timeout as number) ?? 30_000)); // 校验 URL if (!url || !/^https?:\/\//i.test(url)) { return { error: 'Invalid URL', success: false }; } // #10 修复: SSRF 校验 — 拒绝内网/回环/元数据地址 try { await validateSSRF(url); } catch (ssrfErr) { return { error: (ssrfErr as Error).message, success: false }; } // 校验 method if (!(ALLOWED_METHODS as readonly string[]).includes(method)) { return { error: `Invalid method: ${method}. Must be one of: ${ALLOWED_METHODS.join(', ')}`, success: false, }; } // 超时控制 const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); try { const fetchOptions: RequestInit = { method, headers, signal: controller.signal, // #10 修复: 禁用自动重定向跟随 — 防止重定向到内网地址绕过 SSRF 校验 // 重定向后的 URL 由用户自行处理(响应中会包含 Location 头) redirect: 'manual', }; // GET/HEAD 不应携带 body if (body !== undefined && method !== 'GET' && method !== 'HEAD') { fetchOptions.body = body; } const response = await fetch(url, fetchOptions); const text = await response.text(); // 截断到 50KB const truncated = text.length > MAX_BODY_BYTES; const safeBody = truncated ? text.slice(0, MAX_BODY_BYTES) : text; // 只返回 content-type 和 content-length // 审查修复: redirect:'manual' 后需要返回 Location header,否则 LLM 无法知道重定向目标 const filteredHeaders: Record = {}; const contentType = response.headers.get('content-type'); if (contentType) filteredHeaders['content-type'] = contentType; const contentLength = response.headers.get('content-length'); if (contentLength) filteredHeaders['content-length'] = contentLength; const location = response.headers.get('location'); if (location) filteredHeaders['location'] = location; return { status: response.status, statusText: response.statusText, headers: filteredHeaders, body: safeBody, truncated, ok: response.ok, success: true, // v0.3.1 修复 WARN-4: 成功路径添加 success 字段 }; } finally { clearTimeout(timer); } } catch (error) { // 区分超时(AbortError)与其他网络错误 if (error instanceof Error && error.name === 'AbortError') { return { error: 'Request timeout', success: false }; } const errMsg = error instanceof Error ? error.message : String(error); return { error: errMsg, success: false }; } } }