feat: 升级至 v0.3.16 — 工单修复 51 项 + 审查修复 30 项(安全加固/适配器/工具系统/数据层/前端)
This commit is contained in:
@@ -186,11 +186,44 @@ export class BrowserWindowManager {
|
||||
|
||||
// ===== browserEvaluate =====
|
||||
|
||||
/**
|
||||
* 在浏览器页面上下文中执行 JS
|
||||
*
|
||||
* #46 修复: 增强安全防护
|
||||
* - 审计日志:记录所有 executeJavaScript 调用,便于事后追溯恶意操作
|
||||
* - 超时控制:防止恶意 JS 无限阻塞主进程(Electron 原生不支持超时,用 Promise.race 模拟)
|
||||
*
|
||||
* 注意:完全沙箱隔离较难实现(需要 iframe/Web Worker + API 白名单),
|
||||
* 当前先实现审计 + 超时作为缓解措施。webPreferences 已配置 contextIsolation: true
|
||||
* 和 sandbox: true,Electron API 不会被暴露给页面。
|
||||
*/
|
||||
async evaluate(js: string): Promise<unknown> {
|
||||
this.ensureReady();
|
||||
const result = await this.win!.webContents.executeJavaScript(js, true);
|
||||
if (typeof result === 'string') return result;
|
||||
return result;
|
||||
|
||||
// #46 修复: 审计日志 — 记录所有 executeJavaScript 调用(截断前 500 字符),便于追溯
|
||||
log.info('[BrowserWindowManager] evaluate JS (first 500 chars):', js.substring(0, 500));
|
||||
|
||||
// #46 修复: 超时控制 — 防止恶意 JS 无限阻塞主进程
|
||||
const EVAL_TIMEOUT_MS = 10_000;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
this.win!.webContents.executeJavaScript(js, true),
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
// 审查修复 M17: 超时后中止页面 JS 执行。
|
||||
// executeJavaScript 返回的 Promise 无法取消,页面脚本仍会继续运行,
|
||||
// 调用 webContents.stop() 中止页面正在执行的脚本(win 可能已销毁,try/catch 兜底)。
|
||||
try { this.win?.webContents.stop(); } catch {}
|
||||
reject(new Error(`evaluate timed out after ${EVAL_TIMEOUT_MS}ms`));
|
||||
}, EVAL_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
if (typeof result === 'string') return result;
|
||||
return result;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== browserExtract =====
|
||||
|
||||
@@ -109,7 +109,10 @@ export class CodeSearchTool implements IMetonaTool {
|
||||
rgArgs.push('-g', '!MEMORY.md');
|
||||
if (opts.fileGlob) rgArgs.push('-g', opts.fileGlob);
|
||||
|
||||
rgArgs.push(pattern, searchPath);
|
||||
// #22 修复: 在 pattern 前加 -- 终止选项解析,防止以 - 开头的 pattern 被解释为选项
|
||||
// 攻击场景:pattern="--help" 输出帮助而非搜索,pattern="--ignore-file /etc/passwd" 可读取任意文件,
|
||||
// pattern="--files" 列出所有文件。execFile 已用数组参数防 shell 注入,但 ripgrep 自身选项解析仍需防护
|
||||
rgArgs.push('--', pattern, searchPath);
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync('rg', rgArgs, {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具 + run_command 安全规则
|
||||
*/
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import { exec, execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { resolve } from 'path';
|
||||
import { parse as shellQuoteParse } from 'shell-quote';
|
||||
@@ -27,6 +27,7 @@ import { commandTouchesProtectedFile, isPathWithinWorkspace } from './file-guard
|
||||
import type { SandboxManager } from '../../sandbox/sandbox';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// ===== Windows 编码智能解码 =====
|
||||
// 某些 Windows 程序(dir、systeminfo、ipconfig 等)不响应 chcp 65001,
|
||||
@@ -48,6 +49,43 @@ function decodeBuffer(buf: Buffer): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #9 修复 + 审查修复: 构建安全的子进程环境变量
|
||||
*
|
||||
* 审查修复: 原白名单方案遗漏了 GIT_*/PYTHONPATH/HTTP_PROXY 等常用变量,导致子进程功能破坏。
|
||||
* 改为黑名单方案:剔除包含敏感后缀的变量,保留其余。
|
||||
*/
|
||||
function buildSafeCommandEnv(isWindows: boolean): Record<string, string> {
|
||||
// 敏感变量后缀黑名单
|
||||
const SENSITIVE_SUFFIXES = [
|
||||
'_API_KEY', '_TOKEN', '_SECRET', '_PASSWORD', '_PASSWD',
|
||||
'_CREDENTIAL', '_CREDENTIALS', '_PRIVATE_KEY',
|
||||
];
|
||||
// 敏感变量名黑名单(精确匹配)
|
||||
const SENSITIVE_KEYS = new Set([
|
||||
'DEEPSEEK_API_KEY', 'AGNES_API_KEY', 'MIMO_API_KEY',
|
||||
'GITEA_PASSWORD', 'DATABASE_PASSWORD',
|
||||
]);
|
||||
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, val] of Object.entries(process.env)) {
|
||||
if (!val) continue;
|
||||
if (SENSITIVE_KEYS.has(key)) continue;
|
||||
if (SENSITIVE_SUFFIXES.some((suffix) => key.toUpperCase().endsWith(suffix))) continue;
|
||||
env[key] = val;
|
||||
}
|
||||
|
||||
// 添加必要的运行时变量
|
||||
env.NODE_ENV = 'production';
|
||||
if (isWindows) {
|
||||
env.PYTHONIOENCODING = 'utf-8';
|
||||
env.LANG = 'zh_CN.UTF-8';
|
||||
env.LC_ALL = 'zh_CN.UTF-8';
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
// ===== 9. run_command =====
|
||||
|
||||
export class RunCommandTool implements IMetonaTool {
|
||||
@@ -120,28 +158,46 @@ export class RunCommandTool implements IMetonaTool {
|
||||
// Windows 中文编码修复:通过 chcp 65001 切换控制台到 UTF-8 代码页
|
||||
// 避免 GBK/CP936 输出被 Node.js 按 UTF-8 解码导致中文乱码
|
||||
const isWindows = process.platform === 'win32';
|
||||
const finalCommand = isWindows
|
||||
? `chcp 65001 >nul 2>&1 && ${command}`
|
||||
: command;
|
||||
|
||||
// #8 修复: 优先使用 execFile(不经过 shell,从根本上防止命令注入)
|
||||
// 仅当命令为简单命令(无管道、重定向、&& 等 shell 运算符)时使用 execFile
|
||||
// 复杂命令(含 shell 语法)降级到 exec(已有 SandboxManager + validateCommand 双层校验)
|
||||
const simpleCmd = this.parseCommandSimple(command);
|
||||
|
||||
// 使用 encoding: 'buffer' 获取原始字节,手动智能解码
|
||||
// 部分程序(如 dir、systeminfo)不响应 chcp 65001,仍输出 GBK 字节流
|
||||
const { stdout, stderr } = await execAsync(finalCommand, {
|
||||
// #9 修复: 不透传完整 process.env,仅保留子进程运行所需的最小环境变量集合
|
||||
// 防止 API keys / tokens / passwords 通过环境变量泄露给子进程
|
||||
const execEnv = buildSafeCommandEnv(isWindows);
|
||||
const execOpts = {
|
||||
cwd: resolvedWorkdir,
|
||||
timeout,
|
||||
maxBuffer: 1024 * 1024, // 1MB
|
||||
encoding: 'buffer', // 返回 Buffer 而非字符串,便于智能解码
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'production',
|
||||
...(isWindows ? {
|
||||
// 强制常见程序使用 UTF-8 输出
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
LANG: 'zh_CN.UTF-8',
|
||||
LC_ALL: 'zh_CN.UTF-8',
|
||||
} : {}),
|
||||
},
|
||||
});
|
||||
encoding: 'buffer' as const, // 返回 Buffer 而非字符串,便于智能解码
|
||||
env: execEnv,
|
||||
};
|
||||
|
||||
let stdout: Buffer;
|
||||
let stderr: Buffer;
|
||||
|
||||
// #8 修复 + 审查修复: 简单命令使用 execFile(不经过 shell,防止命令注入)
|
||||
// 但 Windows 上 npm/npx/yarn/pnpm/tsc 等是 .cmd 批处理,execFile 无法执行(ENOENT)
|
||||
// 因此 Windows 上仍用 exec(已有 SandboxManager.scanCode + validateCommand 双层校验)
|
||||
// 非 Windows 上对简单命令用 execFile
|
||||
if (simpleCmd && !isWindows) {
|
||||
const result = await execFileAsync(simpleCmd.command, simpleCmd.args, execOpts);
|
||||
stdout = result.stdout;
|
||||
stderr = result.stderr;
|
||||
} else {
|
||||
// 复杂命令(含管道/重定向/&& 等 shell 语法)或 Windows — 使用 exec
|
||||
// 已有 SandboxManager.scanCode + validateCommand 双层安全校验
|
||||
const finalCommand = isWindows
|
||||
? `chcp 65001 >nul 2>&1 && ${command}`
|
||||
: command;
|
||||
const result = await execAsync(finalCommand, execOpts);
|
||||
stdout = result.stdout;
|
||||
stderr = result.stderr;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -336,4 +392,41 @@ export class RunCommandTool implements IMetonaTool {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* #8 修复: 将命令解析为简单的 command + args 数组
|
||||
*
|
||||
* 使用 shell-quote 解析命令,如果命令只包含 word tokens(无管道、重定向、&& 等 shell 运算符),
|
||||
* 返回 { command, args } 供 execFile 使用(不经过 shell,从根本上防止命令注入)。
|
||||
* 如果包含 shell 运算符或解析失败,返回 null,调用方降级到 exec(已有安全校验)。
|
||||
*
|
||||
* @returns 简单命令的 { command, args },或 null 表示复杂命令
|
||||
*/
|
||||
private parseCommandSimple(command: string): { command: string; args: string[] } | null {
|
||||
let tokens: ReturnType<typeof shellQuoteParse>;
|
||||
try {
|
||||
tokens = shellQuoteParse(command);
|
||||
} catch {
|
||||
// 解析失败(Windows cmd 语法、不完整引号等)— 降级到 exec
|
||||
return null;
|
||||
}
|
||||
|
||||
const words: string[] = [];
|
||||
for (const entry of tokens) {
|
||||
if (typeof entry === 'string') {
|
||||
words.push(entry);
|
||||
} else if (typeof entry === 'object' && entry !== null) {
|
||||
const obj = entry as { word?: unknown; op?: unknown };
|
||||
if (typeof obj.word === 'string') {
|
||||
words.push(obj.word);
|
||||
} else if (typeof obj.op === 'string') {
|
||||
// 遇到 shell 运算符(&&、|、;、> 等)— 复杂命令,降级到 exec
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (words.length === 0) return null;
|
||||
return { command: words[0], args: words.slice(1) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,43 @@ import {
|
||||
FILE_TOOL_TIMEOUT_MS,
|
||||
} from './file-guard';
|
||||
|
||||
/**
|
||||
* #45 修复: 检测潜在灾难性正则模式(ReDoS 风险)
|
||||
*
|
||||
* 灾难性回溯通常由以下模式引起:
|
||||
* - 嵌套量词:(a+)+、(a*)*、(a+)*
|
||||
* - 重叠量词:a+a+、a+.*a+(两个量词之间无固定字符分隔)
|
||||
* - 交替分支加量词:(a|a)*
|
||||
*
|
||||
* 这些模式在长字符串上执行时间指数级增长,可阻塞主进程
|
||||
*
|
||||
* @param pattern 用户提供的正则模式字符串
|
||||
* @returns true 如果检测到潜在灾难性模式
|
||||
*/
|
||||
function isPotentiallyCatastrophicRegex(pattern: string): boolean {
|
||||
// 审查修复: 放宽规则减少误报,补充漏报检测
|
||||
|
||||
// 1. 嵌套量词(捕获组内量词+外层量词)
|
||||
// 审查修复: 区分外层量词类型 — 外层 +* 时组内一个量词即可触发(如 (a+)+),
|
||||
// 外层 ? 时需组内两个量词才触发(排除 (\d+)? 误报)
|
||||
if (/\([^)]*[+*?][^)]*\)[+*]/.test(pattern)) return true;
|
||||
if (/\([^)]*[+*?][^)]*[+*?][^)]*\)[?]/.test(pattern)) return true;
|
||||
|
||||
// 2. 重叠量词 — 补充 a+a+ 漏报
|
||||
if (/[+*][+*]/.test(pattern)) return true;
|
||||
// 审查修复: 补充 a+a+ / a+.*a+ 等重叠量词检测
|
||||
if (/\w[+*]\s*\w[+*]/.test(pattern)) return true;
|
||||
if (/\.\*[+*]\.\*[+*]/.test(pattern)) return true;
|
||||
|
||||
// 3. 交替分支加量词 — 放宽: 仅当分支有重叠前缀时才危险
|
||||
// 移除对 (GET|POST)+ 的误报,只检测真正危险的重叠分支
|
||||
// (a|a)* 类型难以用正则精确检测,保留简化版
|
||||
if (/\(([^)]+)\|(\1[^)]*)\)[+*?]/.test(pattern)) return true;
|
||||
if (/\(([^)]*\|[^)]*)\)[+*?]/.test(pattern) && /(.)\1.*\|.*\1/.test(pattern)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export class FileEditorTool implements IMetonaTool {
|
||||
readonly definition: MetonaToolDef = {
|
||||
name: 'file_editor',
|
||||
@@ -159,6 +196,11 @@ export class FileEditorTool implements IMetonaTool {
|
||||
if (pattern.length > 500) {
|
||||
return { success: false, error: 'Regex pattern too long (max 500 chars)' };
|
||||
}
|
||||
// #45 修复: ReDoS 防护 — 检测灾难性正则模式,拒绝可能导致指数级回溯的 pattern
|
||||
// 攻击场景:恶意 LLM 传入 (a+)+ 等模式,在长字符串上阻塞主进程
|
||||
if (isPotentiallyCatastrophicRegex(pattern)) {
|
||||
return { success: false, error: 'Potentially catastrophic regex pattern detected (ReDoS risk): nested or overlapping quantifiers' };
|
||||
}
|
||||
const startLine = Math.max(1, (args.start_line as number) ?? 1);
|
||||
const endLine = Math.min(lines.length, (args.end_line as number) ?? lines.length);
|
||||
if (endLine < startLine) {
|
||||
@@ -178,6 +220,15 @@ export class FileEditorTool implements IMetonaTool {
|
||||
if (multiline) {
|
||||
// F2-6: 跨行匹配 — 对整个目标内容做正则替换(支持多行代码块替换)
|
||||
const targetContent = lines.slice(startLine - 1, endLine).join('\n');
|
||||
// #45 修复: 限制 multiline 模式的目标内容长度,防止大文件上的 regex 执行导致主进程阻塞
|
||||
// Node.js RegExp 执行是同步的,超长内容 + 复杂正则可阻塞事件循环
|
||||
const MAX_REGEX_CONTENT_LENGTH = 100_000;
|
||||
if (targetContent.length > MAX_REGEX_CONTENT_LENGTH) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Target content too large for multiline regex (${targetContent.length} chars, max ${MAX_REGEX_CONTENT_LENGTH}). Use a smaller line range or split the operation.`,
|
||||
};
|
||||
}
|
||||
const matches = targetContent.match(regex);
|
||||
if (matches) replaceCount = matches.length;
|
||||
const replacedContent = targetContent.replace(regex, replacement);
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
* @see standard/开发规范.md — 使用 fs/path 内置模块
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, readdir, stat, mkdir, open, unlink, rmdir, rm, rename, type FileHandle } from 'fs/promises';
|
||||
import { readFile, writeFile, readdir, stat, mkdir, open, unlink, rmdir, rm, rename, realpath, type FileHandle } from 'fs/promises';
|
||||
import { join, relative, resolve, dirname } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { existsSync, realpathSync } from 'fs';
|
||||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||||
import type { MetonaToolDef } from '../../../harness/types';
|
||||
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||||
@@ -542,7 +542,23 @@ export class SearchFilesTool implements IMetonaTool {
|
||||
fileGlob?: string,
|
||||
includeHidden?: boolean,
|
||||
workspacePath?: string,
|
||||
// #21 修复: 添加深度限制,防止深层嵌套目录触发栈溢出
|
||||
depth: number = 0,
|
||||
// #21 修复: 最大递归深度(默认 20),超过则停止递归
|
||||
maxDepth: number = 20,
|
||||
// #21 修复: 符号链接循环防护 — 记录已访问的 realpath,防止 A -> B -> A 死循环
|
||||
visited: Set<string> = new Set(),
|
||||
): Promise<void> {
|
||||
// #21 修复: 深度限制 — 超过 maxDepth 立即返回,防止栈溢出
|
||||
if (depth > maxDepth) return;
|
||||
|
||||
// #21 修复: 符号链接循环防护 — realpath 去重,防止 A -> B -> A 形成循环导致死循环
|
||||
// 审查修复: 改用异步 fs.promises.realpath,避免同步 realpathSync 阻塞 Electron 主进程。
|
||||
// realpath 失败(权限问题等)时回退到 dirPath 本身,保守继续。
|
||||
const real = await realpath(dirPath).catch(() => dirPath);
|
||||
if (visited.has(real)) return;
|
||||
visited.add(real);
|
||||
|
||||
try {
|
||||
const entries = await readdir(dirPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
@@ -552,7 +568,10 @@ export class SearchFilesTool implements IMetonaTool {
|
||||
|
||||
const fullPath = join(dirPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await this.walkDir(fullPath, callback, fileGlob, includeHidden, workspacePath);
|
||||
// #21 修复: 不跟随符号链接目录,防止循环遍历与越权访问
|
||||
if (!entry.isSymbolicLink()) {
|
||||
await this.walkDir(fullPath, callback, fileGlob, includeHidden, workspacePath, depth + 1, maxDepth, visited);
|
||||
}
|
||||
} else {
|
||||
// F2-4: 支持多 glob(逗号分隔,如 "*.ts,*.js,*.tsx")
|
||||
if (fileGlob && !matchAnyGlob(entry.name, fileGlob)) continue;
|
||||
@@ -640,9 +659,35 @@ export class DeleteFileTool implements IMetonaTool {
|
||||
}
|
||||
|
||||
try {
|
||||
// #20 修复: TOCTOU 竞态防护 — 删除前 realpath 二次校验
|
||||
// 攻击场景:在校验(safeResolvePath)与删除(unlink/rmdir)之间,
|
||||
// 攻击者用符号链接替换目标文件,使 unlink 实际删除符号链接指向的工作空间外文件
|
||||
// 防护:记录删除前 realpath,删除前再次校验未被替换
|
||||
let realBefore: string;
|
||||
try {
|
||||
realBefore = realpathSync(resolvedPath);
|
||||
} catch {
|
||||
// realpath 失败(路径不存在等),使用 resolvedPath
|
||||
realBefore = resolvedPath;
|
||||
}
|
||||
if (!isPathWithinWorkspace(realBefore, context.workspacePath)) {
|
||||
return { error: 'Path escape detected (TOCTOU protection)', path: filePath, success: false };
|
||||
}
|
||||
|
||||
const stats = await stat(resolvedPath);
|
||||
const isDirectory = stats.isDirectory();
|
||||
|
||||
// #20 修复: 删除前再次 realpath 校验,确保文件未被替换为指向工作空间外的符号链接
|
||||
let realAfter: string;
|
||||
try {
|
||||
realAfter = realpathSync(resolvedPath);
|
||||
} catch {
|
||||
realAfter = resolvedPath;
|
||||
}
|
||||
if (realAfter !== realBefore) {
|
||||
return { error: 'TOCTOU detected: file replaced during deletion', path: filePath, success: false };
|
||||
}
|
||||
|
||||
if (isDirectory) {
|
||||
if (recursive) {
|
||||
// 递归删除非空目录
|
||||
|
||||
@@ -211,6 +211,23 @@ export class GitDiffTool implements IMetonaTool {
|
||||
const pathspec = args.pathspec as string | undefined;
|
||||
const contextLines = Math.max(0, (args.contextLines as number) ?? 3);
|
||||
|
||||
// #19 修复: 校验 pathspec 在工作空间内,防止读取任意仓库差异
|
||||
// 攻击场景:pathspec 传 "../../../other-repo/" 或绝对路径可越权读取工作空间外的 git 差异
|
||||
// resolve 后 isPathWithinWorkspace 校验,glob 字符(*?)不影响前缀校验
|
||||
if (pathspec) {
|
||||
// 审查修复 M18: git magic pathspec(以 : 开头,如 :(glob)**/*.ts)跳过 resolve 校验。
|
||||
// resolve 会把 magic 前缀当普通字符拼接到路径,导致 pathspec 被污染;
|
||||
// git magic 语法本身不构成路径越权风险(git 自身解析 magic 前缀,不指向工作空间外)。
|
||||
if (pathspec.startsWith(':')) {
|
||||
// git magic pathspec,跳过 resolve 校验直接放行
|
||||
} else {
|
||||
const resolved = resolve(context.workspacePath, pathspec);
|
||||
if (!isPathWithinWorkspace(resolved, context.workspacePath)) {
|
||||
return { error: `Path outside workspace: ${pathspec}`, success: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const gitArgs = ['diff'];
|
||||
if (cached) gitArgs.push('--cached');
|
||||
gitArgs.push(`--unified=${contextLines}`);
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
*
|
||||
* 使用 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';
|
||||
@@ -14,6 +18,120 @@ 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<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 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',
|
||||
@@ -34,8 +152,8 @@ export class HttpRequestTool implements IMetonaTool {
|
||||
required: ['url'],
|
||||
},
|
||||
category: MetonaToolCategory.NETWORK,
|
||||
riskLevel: MetonaRiskLevel.LOW,
|
||||
requiresPermission: false,
|
||||
riskLevel: MetonaRiskLevel.MEDIUM,
|
||||
requiresPermission: true,
|
||||
timeoutMs: 30_000,
|
||||
};
|
||||
|
||||
@@ -52,6 +170,13 @@ export class HttpRequestTool implements IMetonaTool {
|
||||
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 {
|
||||
@@ -69,7 +194,9 @@ export class HttpRequestTool implements IMetonaTool {
|
||||
method,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
redirect: 'follow',
|
||||
// #10 修复: 禁用自动重定向跟随 — 防止重定向到内网地址绕过 SSRF 校验
|
||||
// 重定向后的 URL 由用户自行处理(响应中会包含 Location 头)
|
||||
redirect: 'manual',
|
||||
};
|
||||
// GET/HEAD 不应携带 body
|
||||
if (body !== undefined && method !== 'GET' && method !== 'HEAD') {
|
||||
@@ -84,11 +211,14 @@ export class HttpRequestTool implements IMetonaTool {
|
||||
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,
|
||||
|
||||
@@ -95,20 +95,39 @@ export class ToolRegistry {
|
||||
}
|
||||
|
||||
const startTs = Date.now();
|
||||
const timeoutMs = tool.definition.timeoutMs;
|
||||
|
||||
// #12 修复: timeoutMs 为 undefined 时使用默认值,并校验有效性
|
||||
// 防止 setTimeout(fn, undefined) 被解释为 setTimeout(fn, 0) 立即触发超时
|
||||
// 类型定义中 timeoutMs 是必填 number,但 MCP/外部工具运行时可能缺失,需防御
|
||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||
const rawTimeout = tool.definition.timeoutMs;
|
||||
const timeoutMs =
|
||||
typeof rawTimeout === 'number' && rawTimeout > 0
|
||||
? rawTimeout
|
||||
: DEFAULT_TIMEOUT_MS;
|
||||
|
||||
// #11 修复: 使用 AbortController 在超时后通知工具中止,防止 Promise 未取消导致资源泄漏
|
||||
// 原实现 Promise.race 超时后 tool.execute() 仍在后台运行,持续消耗资源
|
||||
// 现通过 signal 传给 context,工具可在耗时操作前检查 signal.aborted 自行中止
|
||||
const controller = new AbortController();
|
||||
const enhancedContext: ToolExecutionContext = {
|
||||
...context,
|
||||
signal: controller.signal,
|
||||
};
|
||||
|
||||
// M-15 修复: 使用 try/finally 清理 setTimeout,防止事件循环 timer 堆积
|
||||
// 工具正常完成时未触发的 timer 会持续占用事件循环 timeoutMs 毫秒
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
// 带超时执行 — 使用 Promise.race 防止工具卡死阻塞 Agent Loop
|
||||
// #11: 超时触发 controller.abort(),支持 signal 的工具可据此中止后台操作
|
||||
const result = await Promise.race([
|
||||
tool.execute(toolCall.args, context),
|
||||
tool.execute(toolCall.args, enhancedContext),
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error(`Tool execution timed out after ${timeoutMs}ms`)),
|
||||
timeoutMs,
|
||||
);
|
||||
timer = setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(new Error(`Tool execution timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user