别人给你的那个 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 目录抓取。
也就是说:一道门装了锁,旁边七个窗没装。这正是安全修复里最常见也最值得警惕的失败模式。
修法:三道判定,一层比一层难绕过
这次改动没有新写一套防护,而是把已有的防护完整地套上去,并把三个容易被绕过的点补严:
- 取之前先判定。 每个抓取点先过
is_safe_url,被拒的地址连连接都不会建立,错误信息是Blocked request to private/internal address: 127.0.0.1这类明确说明。 - 连接时再判定一次。 通过
create_ssrf_safe_client建客户端,策略在 TCP 连接那一刻重新应用,并且直接拨通校验过的那个 IP(同时保留 Host/SNI)。这一步封掉的是所谓 DNS 重绑定(TOCTOU)窗口:第一次解析返回公网地址通过检查,第二次解析返回内网地址 —— 只在「取之前」判定的实现会被这样绕过。 - 手动、有上限的重定向循环。 最多 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 响应体当图片缓存 | 失败关闭 |
你现在需要做什么
三条实操建议,按重要性排序:
- 面向公网的网关,先去确认
allow_private_urls是关的。hermes config get security.allow_private_urls,没设置就是默认的关闭状态。之前为了某个内部服务打开过、后来忘了改回来的情况很常见。 - 如果你在局域网里跑图片生成或本地模型,别急着全局放开:先确认你需要的只是「provider 返回的结果 URL 被本地缓存」,那就只开这一个开关;如果你用的是 fake-ip 模式的 TUN 代理,优先用更窄的
fake_ip_ranges。 - 云端机器上加一层常识防护。 这类防护是「不可能被绕过的门」,但实例元数据凭证还可以通过终端命令去拿 —— 那一侧 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)连「取之前判定」这种基础实现都拦不住。真正稳的写法是三件事同时做到:入口判定、连接时再判定、每一跳重新判定 —— 并且给确实需要内网访问的人留一个明确、可审计、范围尽量小的开关。