P1 修复面收口: v0.6.3 截断自愈推全量(Anthropic/Ollama/非流式/引擎兜底); SSE 上游错误帧检测进重试通道; clearMessages 摘要游标根治; truncateResult 内联图片白名单统一; 前端四 bug(确认弹窗锁死/MemoryViewer/ Virtuoso Footer/abort 尾部过滤) + reasoning 缓冲跨迭代污染; 托盘通知过滤与新建会话死链接线 P2 安全纵深: MCP 审批闭环(ConfirmationHook×PolicyEngine 联动+重名拒注册); SSRF 收敛 ssrf-guard 共享模块 (web_fetch 双通道校验+重定向终态复检); Electron 加固(preload CJS 化→sandbox:true/CSP/权限白名单/will-navigate); run_command cmd.exe 白名单通道元字符守门; diff_viewer 10MB 预检; Anthropic thinking 预算下限; Agnes 思考显式关闭 P3 架构还债: OpenAICompatibleAdapter 中间基类收敛四家样板; 错误分类单轨化(删 mapError/getFetchSignal, 超时显式 ETIMEDOUT); PRAGMA user_version 迁移版本化; 死代码清理专项(cn.ts/SHORTCUTS/ContextMenu 分支/ getWindowState/modifiedArgs/sandbox 空壳); i18next 引入; a11y 第一轮; SearXNG 页批量草稿模型统一 P4 能力演进: Ollama pull 可取消/capabilities 探测/num_ctx 实测缓存; UpdateService feed 比对式自动更新 (app:updateCheck IPC + StatusBar 入口); MiMo providerOptions(web_search 服务端工具/strict JSON); web_fetch extract_mode=markdown(turndown); network.proxyUrl 全局代理(Chromium sessions+undici dispatcher) 测试: 264 → 507 用例(Electron ABI 全绿零跳过), 覆盖引擎压缩管线/重试竞速/MEMORY.md 闸门/file_editor 五操作/ filesystem 七工具实体夹具/git 真实仓库/SSE 错误帧/全线截断自愈/Provider 请求形态矩阵/SSRF 表测/钩子分级矩阵/ OutputValidator 全量/SLO 指标/MCP 安全纯函数/task_manager 链路/渲染层纯域/i18n 桥契约
162 lines
6.6 KiB
TypeScript
162 lines
6.6 KiB
TypeScript
/**
|
||
* 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 无校验造成工具层最大的安全不对称。单源后所有网络工具行为一致。
|
||
import { validateSSRF } from './ssrf-guard';
|
||
|
||
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<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
|
||
try {
|
||
const url = args.url as string;
|
||
const method = ((args.method as string) ?? 'GET').toUpperCase();
|
||
const headers = (args.headers as Record<string, string> | 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<string, string> = {};
|
||
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 };
|
||
}
|
||
}
|
||
}
|