Files
metona-ollama-desktop/src/main/net-guard.ts
T
thzxx bae993c321
CI / verify (push) Canceled after 0s
v0.17.0: 安全加固 + 数据层性能重构 + 死代码清理 + 测试基建
安全修复:
- 开启 webSecurity(CORS 改为 webRequest 允许清单精确放行 Ollama 地址)
- 新增 net-guard SSRF 防护:web_fetch/download_file/browser_open 拦截环回/内网/链路本地地址(DNS 解析后校验)
- browser_open 协议白名单(仅 http/https,阻止 file:// 绕过路径安全层)
- git 参数注入防护(用户可控参数禁止 - 开头;git add 强制 -- 分隔)
- 身份文件保护:SOUL.md/AGENT.md/USER.md 工具只读(防提示注入持久化劫持)
- 系统目录硬红线 + 工作空间/白名单不可豁免系统目录
- spawn_task 权限只降不升(封顶于用户设置 subAgentMaxPermission)
- 子代理写类工具接入主 Agent 确认管线 + 完整路径沙箱
- toast 改 textContent、HTML 导出 escapeHtml(XSS 修复)
- Agent 浏览器改用 memory: 内存分区(退出清空 cookie/storage)

数据层重构:
- sql.js 写入改防抖批量落盘(300ms 合并快照 + temp 原子替换 + 退出刷盘)
- Schema 迁移改 PRAGMA user_version 顺序迁移数组
- 消息/设置/轨迹批量写(单事务);SearXNG 配置 13 次写合并为 1 次
- 会话摘要查询(getSessionSummaries/searchSessions 单条 SQL)消除 N+1
- 导出改 getAllSessionsData 一次 IPC 取回全部行

Bug 修复:
- edit_file 替换符污染($&/$1 被特殊解释导致文件写坏)
- truncateToolResult 暴力截断拼接非法 JSON 必然崩溃
- diff 算法 100MB dp 数组 → 前缀/后缀裁剪 + LCS 限额 + 回退
- move_file 跨盘 rename 失败回退 copy+delete
- Ctrl+K 快捷键冲突(双注册);全局错误处理器双注册
- ffmpeg stderr 无限累积 + 帧进度 O(n²) 正则
- 搜索可达性预检只取响应头(Range: bytes=0-0)
- 备份导出逐字节 base64 拼接(O(n²))改 FileReader
- MCP clientInfo 版本硬编码 5.0.0 改真实版本;tools/list 支持 nextCursor 分页
- 看门狗默认值统一为 30 分钟;download_file 超时跟随用户配置

架构改进:
- 主进程工具分发注册表 tool-dispatch.ts(消除 switch 硬编码)
- agent-engine 拆分 result-formatter.ts / tool-parsing.ts(纯函数)
- 文本兜底解析白名单改从注册表派生(补齐 browser_*/diff/spawn_task/mcp_*)
- diff 工具默认启用;MODE_TOOLS 单一事实来源(tools-modal 复用)
- 记忆系统:条目缓存 + 访问统计(hits/last)持久化 + removeById 按 ID 删除
- 度量历史启动恢复 + Metrics 仪表盘接入 JSON/Prometheus 导出
- 子代理模型下拉框打开设置时刷新(此前从未填充)

死代码清理(约 1400 行):
- 删除 context-indexer 整模块、agent-safety 震荡检测/性能报告/依赖图/记忆调优/归档取回
- 删除 context-manager 水印/跳过压缩/自适应窗口/趋势分析/预算分配等未接线函数
- 删除 sanitizeToolArgs(污染 write_file 内容,防注入职责移交主进程安全层)
- infra-service 裁剪为全局错误处理器唯一定义

文档对齐:
- 新增内置 AGENT.md(工作空间同名文件可覆盖)
- README/帮助面板/DEVELOPMENT 移除失实描述(WAL/内部URL拦截/5层防御/并行白名单/Hook 数量)
- 工具数量口径统一 33;安全机制表新增 SSRF/身份保护/子代理权限等 9 项

工程化:
- Vitest + 34 个单元测试(myers-diff/calculator/net-guard/MEMORY.md 格式)
- Gitea Actions CI(typecheck + test + build)
- package.json 新增 typecheck/test 脚本
2026-08-24 07:39:34 +08:00

94 lines
2.9 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.
/**
* Net Guard — 内网地址访问防护(SSRF 防御)
* 阻止工具(web_fetch / download_file / browser_open)访问环回/内网/链路本地地址,
* 防止网页提示注入诱导 Agent 探测本机服务(如 Ollama API)或内网资源后外传数据。
*
* 已知限制:检查在请求前解析 DNS,fetch 时会再次解析,理论上存在 DNS rebinding 窗口。
*/
import * as dns from 'dns';
import * as net from 'net';
/** 判断 IP 是否为环回/内网/链路本地地址 */
export function isPrivateIp(ip: string): boolean {
const normalized = ip.toLowerCase();
// 环回与未指定地址
if (
normalized === '127.0.0.1' ||
normalized === '0.0.0.0' ||
normalized === '::1' ||
normalized === '::' ||
normalized === '::ffff:127.0.0.1'
) {
return true;
}
if (net.isIPv4(normalized)) {
return (
normalized.startsWith('10.') ||
normalized.startsWith('192.168.') ||
normalized.startsWith('169.254.') ||
/^172\.(1[6-9]|2\d|3[01])\./.test(normalized)
);
}
if (net.isIPv6(normalized)) {
// IPv6 环回 / ULAfc00::/7fd 开头)/ 链路本地(fe80::/10
return (
normalized.startsWith('fc') ||
normalized.startsWith('fd') ||
normalized.startsWith('fe80')
);
}
return false;
}
/** 本机/内网域名后缀(无需 DNS 解析即可判定) */
const LOCAL_HOST_SUFFIXES = ['.localhost', '.local', '.internal', '.lan', '.home.arpa'];
/**
* 校验 URL 是否可被工具访问:
* - 仅允许 http/https 协议
* - 拒绝 localhost 及本地域名后缀
* - 拒绝字面量内网 IP
* - 域名需 DNS 解析后确认不指向内网地址
*/
export async function checkPublicHttpUrl(rawUrl: string): Promise<{ ok: boolean; reason?: string }> {
let u: URL;
try {
u = new URL(rawUrl);
} catch {
return { ok: false, reason: `无效 URL: ${rawUrl}` };
}
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
return { ok: false, reason: `不允许的协议: ${u.protocol}` };
}
const host = u.hostname.toLowerCase();
if (host === 'localhost') {
return { ok: false, reason: '禁止访问本机地址 localhost' };
}
if (LOCAL_HOST_SUFFIXES.some(sfx => host.endsWith(sfx))) {
return { ok: false, reason: `禁止访问本地域名: ${host}` };
}
// 字面量 IP 直接判定
if (net.isIP(host)) {
if (isPrivateIp(host)) {
return { ok: false, reason: `禁止访问内网/环回地址: ${host}` };
}
return { ok: true };
}
// 域名:解析后逐个检查结果 IP
try {
const records = await dns.promises.lookup(host, { all: true });
for (const r of records) {
if (isPrivateIp(r.address)) {
return { ok: false, reason: `域名 ${host} 解析到内网地址 ${r.address}` };
}
}
} catch {
return { ok: false, reason: `域名解析失败: ${host}` };
}
return { ok: true };
}