别人给你的那个 URL,Hermes 不再闭着眼睛去取


你在家里那台小服务器上跑着一个图片生成服务,顺手把它的地址配成了 provider 的 base_url。某天 agent 帮你生成一张图,拿回来的「图片 URL」指向 http://127.0.0.1:8080/admin/... —— 而 Hermes 老老实实去取了,把它当图片存了下来。或者换个更糟的剧本:某张图的结果 URL 指向 169.254.169.254(云厂商的实例元数据服务),那台机器上的临时 IAM 凭证就顺着这条看起来像「下载图片」的路径走了一遭。

这些 URL 有一个共同点:不是用户选的。它们来自 provider 的返回值、模型自己填的字段、第三方索引的条目 —— 也就是攻击面最标准的位置。9 月 18 日合入的 PR #114887 把七个这样的抓取点,统一接到了仓库里本来就有的那道 SSRF 防护上。

问题的形状:七个抓取点还在用裸请求

Hermes 早就有一套 URL 安全判定(tools/url_safety.py)—— 平台媒体下载、web 工具、浏览器路径都在用它。但另外七处直接调用了 requests.get / httpx.get / urllib.request.urlopen,抓到的是它们没有选择权的 URL:

  • agent/provider_media.py::save_url —— 图片生成 provider 返回的结果 URL(save_url_image 与 save_url_video 共用);
  • OpenAI 与 OpenAI-Codex 两个图片生成插件里,把远端图片读成字节/数据 URL 的那两处;
  • 宠物商店的 _http_get / _download,以及 petdex 清单的获取;
  • TUI 网关里把远端图片转成 data URL 的那一处;
  • skills.sh 的 sitemap 目录抓取。

也就是说:一道门装了锁,旁边七个窗没装。这正是安全修复里最常见也最值得警惕的失败模式。

修法:三道判定,一层比一层难绕过

这次改动没有新写一套防护,而是把已有的防护完整地套上去,并把三个容易被绕过的点补严:

  1. 取之前先判定。 每个抓取点先过 is_safe_url,被拒的地址连连接都不会建立,错误信息是 Blocked request to private/internal address: 127.0.0.1 这类明确说明。
  2. 连接时再判定一次。 通过 create_ssrf_safe_client 建客户端,策略在 TCP 连接那一刻重新应用,并且直接拨通校验过的那个 IP(同时保留 Host/SNI)。这一步封掉的是所谓 DNS 重绑定(TOCTOU)窗口:第一次解析返回公网地址通过检查,第二次解析返回内网地址 —— 只在「取之前」判定的实现会被这样绕过。
  3. 手动、有上限的重定向循环。 最多 5 跳,每一跳都重新判定;并且遇到没有 Location 头的 3xx 直接失败(之前的实现会把 3xx 的响应体当成图片缓存下来)。另外,调用方自带的请求头(例如 OpenRouter 视频接口的 bearer key)只作用于第一跳,不会被跳转到别的域名时顺手带过去。

判定拦掉的地址类别(官方文档的清单):

  • 私有网段 RFC 1918:10.0.0.0/8、172.16.0.0/12、192.168.0.0/16
  • 回环:127.0.0.0/8、::1
  • 链路本地:169.254.0.0/16(包括云元数据 169.254.169.254)
  • CGNAT / 共享地址空间(RFC 6598):100.64.0.0/10(Tailscale、WireGuard 这类 VPN 常用)
  • 云元数据主机名:metadata.google.internal、metadata.goog
  • 保留、组播、未指定地址

另外两条基调:DNS 解析失败按「拒绝」处理(fail-closed),以及重定向链每一跳都重新校验。

如果你就是要访问内网,官方给了两个口子

这不是「一律封死」的改动。文档里明确了两种例外,而且区分得很清楚:

# 全局放开(默认 false)—— 这是一个明确的信任边界决定
security:
  allow_private_urls: true

# 只给本地代理的 fake-ip 段开口(更窄)
security:
  fake_ip_ranges:
    - 198.18.0.0/15
  • allow_private_urls:适合家用网络把 home.arpa 解析到 RFC 1918、局域网里的 Ollama/llama.cpp 端点、内部 wiki 这类场景。文档的语气也很直接:这是有意的信任边界 —— 只有当「这台机器上运行的 agent 拿着提示注入来的 URL 去打本地网络」这件事的风险可以接受时,才打开它;面向公网的网关应该保持关闭。还有一个细节:拦住「长得像的 Unicode 域名」的主机名子串防护,不受这个开关影响,永远开着。
  • fake_ip_ranges:给 TUN 代理的 fake-ip 模式用(Mihomo/Clash 的 fake-ip、Surge 增强模式)。这类代理会用自己的地址块(默认 198.18.0.0/15)回答所有不在过滤范围内的域名,于是「私有地址防护」会把每一次出网请求都拒掉 —— 报错还是那句「URL targets a private or internal network address」,请求根本没出去。把代理自己拥有的地址段声明出来,放行的只有这一段;回环、RFC 1918、链路本地、CGNAT、云元数据依旧拦着。声明里如果包含这些类别(比如写 0.0.0.0/0 或 ::/0),整条会被忽略并留下警告,而不是悄悄把防护放宽。

还有一个和日常使用最相关的例外:你自己配置的 provider base_url 不算「别人给的 URL」。从你配置的 base_url 直接下载(OpenRouter 视频内容接口那种)在第一跳会跳过私有地址这一类检查,但云元数据的底线仍然生效,而且它发出的任何跳转都会被完整重新校验。所以想在局域网里跑图片生成服务,结果 URL 要被本地缓存下来,就需要 security.allow_private_urls: true。

PR 里的实测:修复前九个点全中,修复后零命中

验证方式很扎实:写一个监听在回环上的探针,谁去连它都会记一笔,然后用真实代码路径驱动每一个抓取点:

场景 修复前 修复后
九个抓取点各喂一个 http://127.0.0.1:端口/... 每个点都命中了监听器,响应体被返回/缓存 零命中,各点抛自己的错误类型(_image_to_data_url 返回 None),错误信息为 Blocked request to private/internal address: 127.0.0.1
第一跳安全、随后 302 跳到 169.254.169.254 真的发起了连接尝试 该跳被拒
无 Location 头的 3xx 把 3xx 响应体当图片缓存 失败关闭

你现在需要做什么

三条实操建议,按重要性排序:

  1. 面向公网的网关,先去确认 allow_private_urls 是关的。 hermes config get security.allow_private_urls,没设置就是默认的关闭状态。之前为了某个内部服务打开过、后来忘了改回来的情况很常见。
  2. 如果你在局域网里跑图片生成或本地模型,别急着全局放开:先确认你需要的只是「provider 返回的结果 URL 被本地缓存」,那就只开这一个开关;如果你用的是 fake-ip 模式的 TUN 代理,优先用更窄的 fake_ip_ranges。
  3. 云端机器上加一层常识防护。 这类防护是「不可能被绕过的门」,但实例元数据凭证还可以通过终端命令去拿 —— 那一侧 Hermes 用的是审批门禁,我们在 169.254.169.254 审批门那篇里详细讲过。两边配合起来才是完整的。

同类话题还可以看 MCP 目录凭据闭集那篇(第三方条目不能凭字段名乱写环境变量),以及 官方远程 MCP 目录的审核方式。安全文档本身在 官方 security 文档。

什么时候能用上

PR #114887 于 9 月 18 日合入 main,当前最新 tag 仍是 v0.21.3(tag 名 v2026.9.14,9 月 14 日发布),因此它还没进正式 tag。git 安装的用户更新一次即生效:

hermes --version
hermes update

Docker 镜像与云端用户等下一个 tag。

小结

这次修复的教训可以概括成一句话:安全防护的价值取决于它覆盖了多少个入口,而不是它写得多严。 七个抓取点用的是裸 HTTP 客户端,其中至少两处(DNS 重绑定、无 Location 的 3xx)连「取之前判定」这种基础实现都拦不住。真正稳的写法是三件事同时做到:入口判定、连接时再判定、每一跳重新判定 —— 并且给确实需要内网访问的人留一个明确、可审计、范围尽量小的开关。