给工具调用划安全红线:SSRF 防护、逐跳复检与沙箱取舍

模型一旦能驱动应用发起请求,安全边界就从「模型只输出文字」变成了「模型输出能触达网络和本地资源」。最经典的灾难场景是:一段提示词诱导应用去请求云服务器的元数据地址,把内网凭据偷出来。这类攻击不针对你的代码漏洞,针对的是「你让模型的输出变成了 HTTP 请求的目标地址」。

这篇讲我们这个本地优先桌面 AI 应用的工具安全设计:SSRF 怎么层层拦、重定向为什么要手动跟随、以及为什么「不做文件系统和命令工具」本身就是一道防线。

为什么工具调用打开了新的攻击面

在接上工具之前,这个应用是一个「被动回答」的助手------模型只能基于用户输入和知识库片段生成文本,没有任何对外动作。这种情况下的安全边界很清晰:模型是只读的、输出只到前端。

引入工具调用后,这个边界破了。fetch_webpage 让模型的输出可以驱动应用去主动发起 HTTP 请求 ;如果以后加了文件系统工具,模型可能驱动应用去读/写本地文件 ;如果加了命令执行工具,甚至可能执行 shell 命令 。此时安全边界变成了:模型 → 工具调用 → 外部动作。中间任何一环松了,都可能变成攻击面。

安全设计围绕一个核心原则:工具执行链路必须在模型可影响的范围之外有独立的安全校验层 。模型的 JSON Schema 是「告诉模型该传什么参数」,不是「保证参数安全」------模型可能幻觉一个 URL、可能诱导应用去请求内网、可能尝试路径遍历。这些都不能仅靠 JSON Schema 的 pattern: ^https:// 来防。

SSRF:fetch_webpage 的核心防线

fetch_webpage 是三个工具里风险最高的------如果模型被诱导请求 http://169.254.169.254/latest/meta-data/(AWS/GCP 云元数据)或 http://127.0.0.1:6379/(本地 Redis),桌面应用跑在用户机器上,等于把本地网络暴露给了模型的输出。

SSRF 防护实现在 packages/core/src/tools/ssrf-guard.ts,分三层:

第一层:URL 字面量校验

typescript 复制代码
export function assertSafeUrlLiteral(urlString: string): URL {
  let url: URL;
  try {
    url = new URL(urlString);
  } catch {
    throw new SsrfBlockedError(`非法 URL:${urlString}`);
  }
  // 协议白名单:仅 http/https
  if (url.protocol !== 'http:' && url.protocol !== 'https:') {
    throw new SsrfBlockedError(`仅允许 http/https 协议:${url.protocol}`);
  }
  // 主机名为 IP 字面量时直接判定
  const host = url.hostname.replace(/^\[|\]$/g, '');
  const ipCheck = isBlockedIp(host);
  if (ipv4ToInt(host) !== null || host.includes(':')) {
    if (ipCheck.blocked) throw new SsrfBlockedError(ipCheck.reason ?? '目标地址被拦截');
  }
  return url;
}

协议白名单拦住 file://、ftp://、gopher://(虽然 fetch 本身不支持这些协议,但显式白名单更安全)。主机名如果是 IP 字面量(比如 127.0.0.1),直接过 CIDR 判定;如果是域名,放行到下一层(DNS 解析阶段校验)。

第二层:CIDR 命中判定

IPv4 覆盖了所有私网/回环/保留段:

typescript 复制代码
const BLOCKED_V4_CIDRS: ReadonlyArray<readonly [string, number]> = [
  ['0.0.0.0', 8],       // 本网段
  ['10.0.0.0', 8],      // 私网 A 类
  ['100.64.0.0', 10],   // CGNAT
  ['127.0.0.0', 8],     // 回环
  ['169.254.0.0', 16],  // 链路本地(含云元数据 169.254.169.254)
  ['172.16.0.0', 12],   // 私网 B 类
  ['192.168.0.0', 16],  // 私网 C 类
  ['192.0.0.0', 24],    // IETF 保留
  ['192.0.2.0', 24],    // TEST-NET-1
  ['192.88.99.0', 24],  // 6to4 中继
  ['198.18.0.0', 15],   // 基准测试
  ['198.51.100.0', 24], // TEST-NET-2
  ['203.0.113.0', 24], // TEST-NET-3
  ['224.0.0.0', 4],     // 多播
  ['240.0.0.0', 4],     // 保留
];

IPv6 覆盖了 fc00::/7(唯一本地)、fe80::/10(链路本地)、ff00::/8(多播),以及 ::ffff:0:0/96 IPv4-mapped 尾段内嵌的 v4 地址(如果是 ::ffff:127.0.0.1 也会被拦截)。

CIDR 命中判定是个纯函数,100% 可单测------ssrf-guard.test.ts 覆盖了所有边界值(127.0.0.0、127.255.255.255、169.254.0.0、169.254.255.255、公网 IP 8.8.8.8、1.1.1.1)以及 IPv6 边界(::1、fc00::1、公网 2001:4860::8888)。

第三层:DNS 解析校验(防 DNS rebinding)

主机名为域名时,光看字面量不行------攻击者可能让域名先解析到公网 IP(通过字面量校验),然后在 fetch 之前切换到内网 IP(DNS rebinding)。

我们的做法是在发请求前立即做 DNS 解析,校验所有解析结果:

typescript 复制代码
export async function resolveAndAssertHost(hostname: string): Promise<void> {
  const records = await dns.lookup(hostname, { all: true });
  if (records.length === 0) throw new SsrfBlockedError(`域名解析无结果:${hostname}`);
  for (const record of records) {
    const check = isBlockedIp(record.address);
    if (check.blocked) throw new SsrfBlockedError(check.reason ?? `域名解析到内网地址:${record.address}`);
  }
}

注意 { all: true } 拿到所有解析结果------有些域名返回多个 A 记录(负载均衡),只要有一个命中内网就拦截。

第四层(隐藏层):重定向逐跳复检

URL 校验通过后,请求发起了。但 302 重定向可能跳到内网------比如 http://evil.com/redirect?url=127.0.0.1 先返回 302 到 http://127.0.0.1/admin。

我们不用 fetch 内置的自动跟随(redirect: 'follow'),而是手动跟随、每跳都过 SSRF 校验 (packages/core/src/net/safe-web-fetch.ts):

typescript 复制代码
export async function followRedirects(initial: URL, signal: AbortSignal) {
  let current = initial;
  for (let hop = 0; hop <= WEB_FETCH_MAX_REDIRECTS; hop += 1) {
    const response = await fetch(current, {
      method: 'GET',
      redirect: 'manual',  // 不自动跟随
    });
    if (response.status >= 300 && response.status < 400) {
      const location = response.headers.get('location');
      // 关键:重定向目标也要重新走 SSRF 校验
      current = await assertEveryHop(new URL(location, current).href);
      continue;
    }
    return { response, finalUrl: current };
  }
}

assertEveryHop = 协议白名单 + IP 字面量判定 + DNS 解析校验。这样不管重定向链多长(上限 3 跳),每一跳都被校验。

文件系统与命令执行:当前明确不做

安全设计里一个容易被忽略的点是:「不做某些功能」也是安全措施。当前我们明确不做两类工具------文件系统访问和命令执行。原因不是技术做不到,而是它们的安全边界太宽:

  • 文件系统 :即使做了路径白名单,模型仍然可能通过路径遍历(../../etc/passwd)或符号链接绕过;文件系统 API 还可能意外泄露本地应用的内部数据(配置文件、密钥)。
  • 命令执行:沙箱隔离的复杂度极高------桌面应用怎么给一个 shell 做隔离?Docker 容器引入太重;PTY 权限控制在不同 OS 上行为不一致;即使用户授权,「给模型一个 shell」本身就是一个危险的设计。

现有的三个工具(current_time、knowledge_search、fetch_webpage)覆盖了「查时间 → 查知识库 → 查公开网页」的闭环。读、只读、完全不碰本地文件系统和 shell 。这不是功能阉割,而是安全边界的明确选择。以后真要做文件系统工具,必须引入独立的路径沙箱层(类似 VSCode 的 workspace.fs 权限模型)和用户确认弹窗(每个写操作都需要用户显式批准)。

参数层:双重校验

fetch_webpage 的参数在进入 SSRF 防护之前还有一层 zod schema 校验:

typescript 复制代码
const argsSchema = z.object({
  url: z.string().trim().min(1).max(2000),
});

这层的作用是提前拦截明显非法的参数(空字符串、超过 2000 字符的 URL),把问题暴露在工具执行器而不是 SSRF 层。两层校验的设计:

层 职责 失败时的行为
zod schema 参数格式合法性 抛 ToolArgError,执行器归一为 ok:false 结果回灌模型
SSRF guard 安全边界 抛 SsrfBlockedError,同样被执行器归一化

注意两者的归一化方式是一样的 ------都变成 ok:false, output:'...' 的 ToolResult 回灌模型。模型看到 「抓取失败:目标地址 127.0.0.1 命中内网/保留网段 127.0.0.0/8」 后,会放弃用工具直接回答。不会因为安全校验失败而向用户暴露原始错误栈。

SsrfBlockedError:安全异常的类型隔离

typescript 复制代码
export class SsrfBlockedError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'SsrfBlockedError';
  }
}

这个自定义异常类让我们可以在 fetch_webpage 的 catch 里区分「SSRF 拦截」和「其他抓取失败」:

typescript 复制代码
try {
  fetched = await safeFetchWebPage(parsed.data.url, ctx.signal);
} catch (error) {
  if (error instanceof SsrfBlockedError) {
    return {
      ok: false,
      output: `抓取失败:目标地址被安全策略拦截。仅支持公开 http/https 网页。`,
      summary: '安全拦截',
    };
  }
  // 其他错误...
}

用户看到的不会是「127.0.0.1 命中 127.0.0.0/8」这种技术细节,而是「目标地址被安全策略拦截」这种友好提示。同时后端日志(如果有的话)会打出完整的 SSRF 错误信息供调试。

超时与大小:资源耗尽防护

安全不只关注「做危险的事」,还要关注「做太多事」。safeFetchWebPage 里有三个硬限制:

typescript 复制代码
export const WEB_FETCH_TIMEOUT_MS = 8_000;
export const WEB_FETCH_MAX_BYTES = 200 * 1024;
export const WEB_FETCH_MAX_REDIRECTS = 3;
  • 8 秒超时:防止模型诱导请求一个慢速地址(比如故意挂一个 30 秒才返回的服务器),拖慢整个对话。
  • 200KB 上限 :响应体超过 200KB 直接拒绝。防止模型请求一个超大文件(比如 http://example.com/large.zip),或者让网页正文撑爆 token 窗口。
  • 3 跳重定向:防止无限重定向链或重定向炸弹。

加上工具执行器本身的 15 秒默认超时 (TOOL_TIMEOUT_MS),整个 fetch_webpage 工具的最坏执行时间约为 3 * 8s + 解析时间 ≈ 25s,仍然在 15 秒工具超时的覆盖内。

小结

工具调用链的安全设计核心是「分层 + 显式拒绝 + 不做」:

威胁 防护层 机制
SSRF IP 字面量 CIDR 硬编码白名单 纯函数、100% 单测
SSRF DNS rebinding fetch 前 DNS 解析 { all: true } 所有结果校验
SSRF 重定向跳转 手动跟随 + 逐跳复检 redirect: 'manual' + assertEveryHop
参数注入 zod schema 提前拦截明显非法参数
资源耗尽 8s 超时 + 200KB 上限 + 3 跳 有界资源使用
文件系统/命令执行 当前不实现 最安全的工具是不存在的工具

所有安全失败都归一为 ok:false 的 ToolResult 回灌模型,不向用户暴露内部错误细节。

相关推荐
是2的10次方啊7 小时前
超时了,然后呢?——聊聊 HTTP / Dubbo 超时后,客户端、服务端与 C 端用户的真实感受
http·dubbo
尘一不染7 小时前
多模型 API 接入实战(一):从一次 HTTP 请求到可维护的适配层
网络·人工智能·网络协议·http
程序员夏洛9 小时前
TCP 是用来解决什么问题?
java·网络协议·http·https
艾莉丝努力练剑11 小时前
【AI大模型接入SDK】ChatServer HTTP接口模块实现总览
网络·c++·人工智能·网络协议·学习·http·架构
青瓦梦滋1 天前
【在线五子棋对战】游戏匹配模块
网络·c++·网络协议·http·https
聚美智数2 天前
网站HTTP状态查询-SEO优化
网络·网络协议·http
云梦谭2 天前
WebSocket 协议详解:从 HTTP 握手到全双工通信
websocket·网络协议·http
白帽攻防录2 天前
SRC 挖洞:QUIC 协议栈双 RCE,HTTP/3 的新攻击面怎么挖
网络·网络协议·http·网络安全·http/3
比兔代理2 天前
HTTP 代理与 TLS 指纹:如何避免被识别为代理流量?
网络·网络协议·http