/** * HTTP 请求工具(1 个) * * http_request — 发送 HTTP/REST API 请求 * * 使用 Node.js 18+ 内置 fetch API。 * 响应体截断到 50KB 防止结果过大。 * * #10 修复: SSRF 防护 — 解析 URL 域名并校验 IP,拒绝内网/回环/元数据地址。 */ import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; import type { MetonaToolDef } from '../../../harness/types'; import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; // v0.6.4 P2-2: SSRF 校验收敛到共享模块 ssrf-guard.ts —— 原实现是本文件私有逻辑, // web_fetch 无校验造成工具层最大的安全不对称。单源后所有网络工具行为一致。 // v0.7.3 P2-1 根治: 请求层升级为 ssrfPinnedFetch —— 校验通过的 IP 集合 pin 到 // 连接层(undici connect.lookup),校验与连接共用同一批 IP,DNS rebinding // 窗口(M7 已知限制)就此关闭;代理激活时自动退化为仅入口校验(见模块注释)。 import { validateSSRF } from './ssrf-guard'; import { ssrfPinnedFetch } from './ssrf-dispatcher'; const ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'] as const; const MAX_BODY_BYTES = 50 * 1024; // 50KB /** * #10 修复: 检查 IP 是否为私有/内网/回环/元数据地址 * * v0.6.4: 实现迁移到共享模块 ssrf-guard.ts(isPrivateIP / validateSSRF), * 本文件仅保留使用方。实现细节与覆盖范围见 ssrf-guard.ts 注释: * - IPv4: 127/8、10/8、192.168/16、172.16-31、169.254/16(云元数据)、0/8、224+/4 * - IPv6: ::1、fe80::/10、fc00::/7、::ffff: 映射 v4(递归检测) */ /** * 审查修复 (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' 禁用自动重定向(防重定向到内网) * - web_fetch 场景下对重定向终态 URL 复检(v0.6.4) */ 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, }; } // 超时控制由 ssrfPinnedFetch 内部管理(超时 → ETIMEDOUT; // 工具执行层的 abort signal 经 context 传入 registry 兜底) { const fetchOptions: RequestInit = { method, headers, // #10 修复: 禁用自动重定向跟随 — 防止重定向到内网地址绕过 SSRF 校验 // 重定向后的 URL 由用户自行处理(响应中会包含 Location 头) redirect: 'manual', }; // GET/HEAD 不应携带 body if (body !== undefined && method !== 'GET' && method !== 'HEAD') { fetchOptions.body = body; } // v0.7.3 P2-1: pinned fetch —— 校验通过的 IP pin 到连接层, // 关闭校验-连接之间的 DNS rebinding 窗口 const response = await ssrfPinnedFetch(url, fetchOptions, timeout); 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 字段 }; } } catch (error) { // 区分超时与其他网络错误:AbortError(外部中断)与 // ETIMEDOUT(ssrfPinnedFetch 超时转译,v0.7.3 P2-1)均归为超时语义 const err = error as Error & { code?: string }; if (err?.name === 'AbortError' || err?.code === 'ETIMEDOUT') { return { error: 'Request timeout', success: false }; } const errMsg = error instanceof Error ? error.message : String(error); return { error: errMsg, success: false }; } } }