Files
metona-ai-desktop/electron/harness/tools/built-in/ssrf-guard.ts
T
thzxx 5b9d4d19b3
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m16s
CI / 全量测试 (Electron ABI) (push) Failing after 6m4s
CI / 产物编译验证 (push) Successful in 11m1s
feat: v0.8.0 流语义补全 · 会话可靠 · 恢复力 — finish_reason 全链路贯通根治"思考中停止" · 2445 用例全量回归
P0 会话可靠性收口(根治"模型思考着会话就停止"):
- P0-1 finish_reason 全链路贯通:DONE 事件与 IterationStep 新增 finishReason,OpenAI 共享 SSE / Anthropic message_delta.stop_reason / Ollama done_reason 三路采集,TRACE 层弃用硬编码 'stop' 记录真值
- P0-2 空响应守卫 + 降级重试:零产出流→可重试错误走退避;思考耗尽输出预算(reasoning-only + length)→自动关闭思考降级重试一次;仍失败→OUTPUT_LENGTH_EXCEEDED 结构化错误 + 故障转移;附带根治 abort 恰逢零工具调用轮被 COMPLETED 抢占的真实缺陷
- P0-3 思考×能力×预算三对齐:DeepSeek/MiMo/Agnes/Ollama 四家 supportsThinking=false 强制不发思考参数;小输出预算告警;设置页联动提示
- P0-4 渲染层可见性:截断/空完成/友好错误三类提示,i18n 全部出层
- P0-5 回归四件套:reasoning-only 终止判定、集成级空闲超时、504 引擎重试归类、思考中 abort→USER_INTERRUPT、P4-2 强制收尾路径

FEAT-1:LLM 设置新增「最大输出上限」——Provider 支持矩阵显隐 + 模型上限钳制提示 + 超限保存警告 + llm.maxTokens 热生效

P1 修复面收口:
- 渲染层三缺陷根治:后台会话回放缓冲(2000 条/4MB 有界 + agent:getReplayState + 事件总线)+ abort 双层自愈 + sendMessage 收尾兜底 + 中断卡片清扫
- 工具 abort 信号全覆盖:web_search/web_fetch/http_request/code_search/git 系列/delegate_task 全部接入引擎中断;web_search 时间预算收敛(720s→≤240s);移除伪造 ToolExecutionContext 与死代码
- 安全:本地 Pinned CONNECT 代理根治浏览器通道 DNS rebinding(校验期 IP pinning,可注入 resolver 表测);配置 URL 域名解析深校验(DeepCheckSoftFailure 软失败);SSE 空 error 帧防御修复;Ollama generate/embed AbortSignal.any 合并
- 缺陷清单:UTF-16 BOM 读取、tmp 同毫秒碰撞(nanoid 后缀)、code_search JS 回退参数对称(case_sensitive/前后文独立)、list_directory include_node_modules、崩溃自愈退避(60s 窗 ≥3 次停 reload)、MemoryViewer/Sidebar i18n 收口

P2 能力演进:
- 会话回收站:SCHEMA_VERSION 3 + 迁移 10(deleted_at,存在性守卫),软删除/恢复/彻底删除/30 天自动清理(启动+24h),searchMessages 聚合剔除,Sidebar 回收站面板
- 会话回放播放器:sessions:listRecordings/readRecording(白名单+目录边界+20MB 上限),SessionReplayPlayer 时间轴/步进/变速,Trace 面板入口
- electron-updater 自动更新:双轨(手动 feed 比对保留),生产环境启动静默检查 + update:status 广播 + app:updateInstall + LogsSettings UpdatePanel + builder publish 配置
- @ 文件提及:workspace.listFiles/readFileClip(边界/512KB/NUL 拒绝/MEMORY.md 保护),ChatInput Fuse 联想+键盘导航+附件管线注入
- MCP Resources/Prompts 发现:可选能力 try/catch 降级,mcp:listServerContents,MCPSettings 展开视图
- 文档对齐:内部 API 标准 HTML(Adapter 清单补 MiMo/已实现注记/STREAM_RESET/DONE.finishReason/ repetition_truncation 映射);README v0.8.0 亮点表

P3 测试基建:
- 新增 4 个测试文件:engine-stream-contract(6)、engine-stream-reliability(4:集成空闲超时/504 重试/思考中 abort/P4-2 强制收尾)、thinking-capability-gate(7)、pinned-proxy(9,含深校验 5)、session-trash(5,DB 域)、use-agent-stream hook 级(5)、agent.test 回放缓冲(2)
- 契约更新:orchestrator 被中断 SubAgent success=false(abort 优先级修复语义)、SSE 空 error 帧、UTF-16 正常读取、DeepSeek 未配置思考显式 disabled、迁移矩阵 v2→3
- 弱断言根治:registry WEBP 单向断言、hooks-contracts 自比恒真、memory 空 token 补强

全量验证:typecheck 0 错误 / lint 0 问题 / 系统 Node 2144 通过(301 DB 用例按 ABI 跳过)/ Electron ABI 2445/2445 全量通过 0 跳过
2026-09-05 20:06:26 +08:00

322 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* SSRF 防护共享模块(v0.6.4 P2-2
*
* 背景:此前完整的 SSRF 校验只存在于 http_request 工具内部 —— web_fetch /
* 浏览器回退完全没有校验且 requiresPermission:falseLLM 可直接抓取
* 127.0.0.1、169.254.169.254 等内网/云元数据地址,属于工具层最大的安全不对称。
*
* 本模块把校验逻辑抽为单一事实来源:
* - isPrivateIP(ip) IPv4/IPv6 私有段判定(含 ::ffff: 映射递归)
* - validateSSRF(url) 校验失败时抛错(原 http_request 契约)
* - safeValidateSSRF(url) 不抛错的便捷包装(工具内 return-style 使用)
*
* 已知限制(与 M7 审查结论一致):DNS rebinding 在 Node fetch 下无法彻底关闭
* (不可自定义 lookup/SNI),缓解措施为"解析全部 IP、任一私有即拒 + 重定向终态复检"。
*/
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
/**
* v0.8.0 P1-5: DNS lookup 可替换点 —— 生产恒为 node:dns/promises 的 lookup
* 测试通过替换 current 注入受控解析结果(ESM namespace 只读,无法直接打桩)。
*/
export const __dnsLookup: { current: typeof lookup } = { current: lookup };
/**
* 检查 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
*/
export 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;
}
/**
* SSRF 校验 + 公网地址解析(单一事实来源)
*
* v0.7.3 P2-1 重构:resolvePublicAddresses 承载全部校验逻辑并返回解析出的
* 公网 IP 集合;validateSSRF 成为它的"只要不抛错"薄包装。这样 pinning 层
* ssrf-dispatcher)能拿到与校验完全同一批 IP,避免"校验一次解析、连接
* 再解析一次"的双解析不一致。
*
* 1. 协议白名单:仅允许 http/https
* 2. hostname 为 IP 时直接检测
* 3. 域名 — DNS 解析后检测所有 IP;任意一个 IP 为私有即拒绝
* (防止 DNS rebinding 中只校验第一个 IP 的绕过)
*
* @returns 校验通过的全部公网 IP(供 DNS pinning 使用)
* @throws 如果 URL 指向私有/内网/回环地址或协议不被允许
*/
export async function resolvePublicAddresses(url: string): Promise<string[]> {
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 [hostname];
}
// 域名 — DNS 解析后检测所有 IP
let addresses: Array<{ address: string }>;
try {
addresses = await __dnsLookup.current(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}`);
}
const publicIps: string[] = [];
for (const { address } of addresses) {
if (isPrivateIP(address)) {
throw new Error(`Blocked SSRF: ${hostname} resolves to private IP ${address}`);
}
publicIps.push(address);
}
return publicIps;
}
/**
* SSRF 校验 — 解析 URL 域名并校验 IPv0.7.3 起为 resolvePublicAddresses 的
* "仅校验不取值"包装,校验逻辑单一来源在后者)
*
* @throws 如果 URL 指向私有/内网/回环地址或协议不被允许
*/
export async function validateSSRF(url: string): Promise<void> {
await resolvePublicAddresses(url);
}
/** validateSSRF 的不抛错包装:返回结构化结果供工具 execute 直接 return */
export async function safeValidateSSRF(
url: string,
): Promise<{ ok: true } | { ok: false; error: string }> {
try {
await validateSSRF(url);
return { ok: true };
} catch (err) {
return { ok: false, error: (err as Error).message };
}
}
/**
* v0.7.4 P2-9: 配置类 URLMCP server url / SearXNG url)的安全校验。
*
* 与工具执行路径(validateSSRF)的区别:MCP/SearXNG 实例由用户在设置页显式
* 配置且常部署在本机/内网(127.0.0.1、192.168.x 的本地 server 是合法用例),
* 不能一刀切拒绝私网。但渲染层可控的 URL 若允许指向云元数据/链路本地高危段,
* XSS 后可作内网探测跳板。
*
* 规则:
* - 协议仅 http/https
* - 阻止:169.254.169.254(云元数据)与 169.254/16 链路本地、0.0.0.0、
* [::](未指定)、fe80::/10(链路本地)、ff00::/8(组播)、
* ::ffff: 映射的 IPv4(递归复用 isPrivateIP 段位判定,云元数据映射也拦)、
* metadata.google.internal 等元数据主机名、224/4 组播与 240/4 保留段
* - 放行:127.0.0.1 / RFC1918 私网(本地/局域网 MCP、SearXNG 合法)
*
* v0.7.4 P2-9 修正:
* - IPv6 死代码:Node URL.hostname 对 IPv6 字面量**带方括号**返回(如 '[::1]'),
* 旧实现直接 isIP(hostname) 对带括号值恒 0 → 全部落入"域名放行"分支,
* [::ffff:169.254.169.254] 云元数据映射被放行。现先去括号再判定。
* - 域名尾点绕过:metadata.google.internal.(合法 FQDN 尾点)先剥离尾点再比对。
*
* @throws 如果 URL 指向高危目标或协议不被允许
*/
export function assertSafeConfigTarget(url: string): void {
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: protocol "${parsed.protocol}" not allowed (only http/https)`);
}
// 去方括号(IPv6 字面量) + 去尾点(FQDN 尾点)后统一判定
let hostname = parsed.hostname.toLowerCase();
if (hostname.startsWith('[') && hostname.endsWith(']')) {
hostname = hostname.slice(1, -1);
}
if (hostname.endsWith('.')) {
hostname = hostname.slice(0, -1);
}
// 云元数据/链路本地高危主机名(域名形式,含尾点剥离后)
const HIGH_RISK_HOSTS = ['metadata.google.internal', 'metadata.google', '169.254.169.254'];
if (HIGH_RISK_HOSTS.some((h) => hostname === h || hostname.endsWith('.' + h))) {
throw new Error(`Blocked: ${hostname} is a cloud metadata / link-local target`);
}
// IP 直连:仅阻止链路本地/组播/保留/0.0.0.0(本地回环与 RFC1918 私网放行)
const ipVersion = isIP(hostname);
if (ipVersion !== 0) {
if (ipVersion === 4) {
const parts = hostname.split('.').map(Number);
const blocked =
(parts[0] === 169 && parts[1] === 254) || // 链路本地(含云元数据)
parts[0] === 0 || // 0.0.0.0/8
parts[0] >= 224; // 组播 + 保留
if (blocked) {
throw new Error(`Blocked: ${hostname} is a link-local/multicast/reserved address`);
}
return;
}
// IPv6:阻止 ::(未指定)、fe80::/10(链路本地)、ff00::/8(组播)、
// 以及 ::ffff: 映射的 IPv4(复用 IPv4 段位判定,云元数据映射一并拦)。
// 注意:Node URL.hostname 对 IPv4-mapped 返回十六进制(::ffff:a9fe:a9fe),
// 需解析为 IPv4 再判定。
const lower = hostname;
const v4MappedMatch = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
if (v4MappedMatch) {
// 两段十六进制 → IPv4:段1<<8|段2
const a = parseInt(v4MappedMatch[1], 16);
const b = parseInt(v4MappedMatch[2], 16);
const v4 = `${a >> 8}.${a & 0xff}.${b >> 8}.${b & 0xff}`;
const parts = v4.split('.').map(Number);
const blocked = (parts[0] === 169 && parts[1] === 254) || parts[0] === 0 || parts[0] >= 224;
if (blocked) {
throw new Error(
`Blocked: ${hostname} (IPv4-mapped) is a link-local/multicast/reserved address`,
);
}
return;
}
if (lower === '::' || lower.startsWith('fe80:') || lower.startsWith('ff')) {
throw new Error(`Blocked: ${hostname} is an unspecified/link-local/multicast address`);
}
return;
}
// 域名(非 IP):允许(DNS 可能解析到内网,但用户显式配置的本地服务是合法场景)
}
/**
* v0.8.0 P1-5: 配置类 URL 的**深校验** —— 在 assertSafeConfigTarget 的静态规则
* 之上对域名做真实 DNS 解析,任一解析结果命中高危段(链路本地/云元数据、0/8、
* 组播保留、::ffff: 映射等价段)即拒绝。
*
* 与 assertSafeConfigTarget 的差异:后者对"域名放行"(不解析 DNS,本地实例
* 域名合法);本函数补上"域名解析到云元数据 IP"的绕过窗口
* (如 attacker.example 解析到 169.254.169.254)。
*
* 放行语义不变:127.0.0.1 / RFC1918 私网(本地 MCP/SearXNG 合法);DNS 解析
* 失败也放行(配置期校验为纵深手段 —— 离线配置合法,且运行时工具路径仍有
* 独立校验;失败仅 WARN 由调用方留痕)。
*
* @throws 如果 URL 解析结果指向高危目标或协议不被允许
*/
export async function assertSafeConfigTargetDeep(url: string): Promise<void> {
// 先执行静态规则(协议白名单/元数据主机名/IP 字面量段位)
assertSafeConfigTarget(url);
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return; // assertSafeConfigTarget 已抛,此处不可达(防御)
}
const rawHostname = parsed.hostname.toLowerCase();
// IPv6 字面量去方括号(与 assertSafeConfigTarget 同口径)
const hostname =
rawHostname.startsWith('[') && rawHostname.endsWith(']')
? rawHostname.slice(1, -1)
: rawHostname;
if (isIP(hostname) !== 0) return; // IP 字面量已由静态规则判定,无需解析
let addresses: Array<{ address: string }>;
try {
addresses = await __dnsLookup.current(hostname, { all: true });
} catch (err) {
// DNS 解析失败:放行(配置期纵深校验不做可用性裁决),由调用方日志留痕
throw new DeepCheckSoftFailure(
`config target DNS resolution failed for ${hostname}: ${(err as Error).message}`,
);
}
for (const { address } of addresses) {
// 复用 isPrivateIP 的完整段位表做"高危段"判定?——不行:isPrivateIP 把
// 回环/RFC1918 也判为私有,而配置路径放行它们。此处仅拦"静态规则拦不到、
// 但解析后才暴露"的高危段:链路本地(含云元数据)、0/8、组播保留。
if (isHighRiskResolvedIP(address)) {
throw new Error(
`Blocked: ${hostname} resolves to high-risk address ${address} (link-local/metadata/multicast)`,
);
}
}
}
/** 高危段判定(仅配置深校验使用):链路本地/0/8/组播保留,放行回环与 RFC1918 */
function isHighRiskResolvedIP(ip: string): boolean {
if (isIP(ip) === 4) {
const parts = ip.split('.').map(Number);
return (parts[0] === 169 && parts[1] === 254) || parts[0] === 0 || parts[0] >= 224;
}
if (isIP(ip) === 6) {
const lower = ip.toLowerCase();
if (lower.startsWith('fe80:') || lower.startsWith('ff')) return true;
if (lower === '::') return true;
const v4MappedMatch = lower.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (v4MappedMatch) return isHighRiskResolvedIP(v4MappedMatch[1]);
return false;
}
return false;
}
/**
* v0.8.0 P1-5: 深校验的"软失败"信号 —— DNS 解析失败不算配置错误(放行),
* 但调用方需要区分"校验通过"与"跳过校验"以便日志留痕。
*/
export class DeepCheckSoftFailure extends Error {
constructor(message: string) {
super(message);
this.name = 'DeepCheckSoftFailure';
}
}