同一个链接,两个 host:Hermes 补上了 URL 校验里的解析器分歧

你让 agent 去抓一个别人发来的链接,看上去就是台普通服务器:跑一下、把内容读回来、总结成摘要,整套流程很顺。可“这个地址能不能访问”的判断,和客户端真正拨出去的那个地址,可能根本不是一个东西。校验认为那是公网 IP,浏览器却把它读成了你自己机器上的端口。10 月 27 日合入的 PR #145578 修的就是这道缝:当同一个 URL 存在两种读法时,安全校验不再猜,而是直接拒绝。
先说清楚“校验”和“拨号”是两件事
Hermes 在真的发请求之前会先做一层地址检查:如果目标是本机地址、内网地址或者云厂商的元数据地址,就不发出去。这套逻辑在 tools/url_safety.py 里,is_safe_url() 管主要判断,is_always_blocked_url() 是更粗的一道兜底。
问题在于,做检查的人用的是 Python 标准库的 urllib.parse(urlsplit),而真正发请求的客户端不一定是 Python 标准库。browser 工具背后是 Chromium;Node 的 fetch 后端(比如自建 Firecrawl)、以及 urllib3,各自也有自己的规矩。现代浏览器遵循的是 WHATWG 的 URL 标准。两套规则面对同一串字符,可以给出不同的 host——而这个 host 恰恰是安全检查唯一的依据。
四个例子:同一串字符,两种读法
PR 里给了四个可以直接对照的例子:
| URL | urlsplit / httpx 读到的 host | WHATWG 读到的 host | main 分支上的结果 |
|---|---|---|---|
http://127.0.0.1:8080\@1.1.1.1 |
1.1.1.1 |
127.0.0.1:8080 |
is_safe_url 返回 True |
http://%31%32%37.0.0.1/ |
%31%32%37.0.0.1 |
127.0.0.1 |
DNS 相关(设了代理且 DNS 解析失败时为 True) |
http://***@1.1.1.1/ |
1.1.1.1 |
169.254.169.254 |
is_always_blocked_url 返回 False |
http:\\169.254.169.254\ |
无 | 169.254.169.254 |
is_always_blocked_url 返回 False |
第一行最能说明问题:urlsplit 看见 @,就认为前面那段是登录凭据,host 是 1.1.1.1,于是放行;浏览器却在反斜杠处结束了 authority,认认真真去连 127.0.0.1:8080。一个“看公网”的地址,实际落在回环口上。
第二行是百分号编码的 host:校验看到的是 %31%32%37.0.0.1 这串字面值,浏览器解码之后得到 127.0.0.1。main 上的结论还和 DNS 有关——设了代理、DNS 又解析失败时,函数返回 True。
第三、四行打的是另一条路径:is_always_blocked_url() 没拦住。这条路径不经过 is_safe_url(),本地 Chromium 后端和 hybrid sidecar 就只依赖它。也就是说,元数据地址的最后一道防线,原本有同样的缺口。
分歧到底出在哪
WHATWG 那套规则做了三件 urlsplit 不会做的事:
- 反斜杠当作斜杠。在 http 这类“特殊协议”里,反斜杠和
/等价,authority 到第一个反斜杠就结束。所以http://127.0.0.1:8080\@1.1.1.1的 authority 只有127.0.0.1:8080。 - host 做百分号解码。
%31%32%37就是127。 - 接受省略斜杠的写法。
http:\\host、http:///host都能被补全成http://host。
urlsplit 三条都不做:它不认反斜杠,不解码 host,缺了 // 就认为根本没有 authority。于是“这个 URL 的 host 是不是内网”这个问题没有唯一答案——答案取决于你问谁。安全检查问的是一套,客户端拨号用的是另一套,中间那段空白就是绕过空间。
修复思路:读法有歧义,就拒绝
修复没有去追着每种解析器对齐,而是换了个方向:只要两边可能读出不同的 host,就当成不安全。术语叫 fail closed——宁可拒掉一个其实没问题的地址,也不去猜客户端会用哪种读法。
具体做法是新增一个辅助函数 _authority_ambiguity()(在 tools/url_safety.py 第 253 行)。对 http(s) URL,它先剥掉普通的首尾空白(空格、tab、CR、LF、VT、FF),然后只要命中下面任何一条就拒绝:
- 任意位置出现 C0 控制字符或 DEL。这条包括尾部,例如
http://169.254.169.254\x01——因为urlsplit会默默丢掉 tab、CR、LF,同一串字符在两边可能被读成不一样的东西。 - 缺少
//authority:http:\\host、http:///host、http:host都算。 - authority 里任意位置出现反斜杠。
- authority 里出现
@(userinfo,is_safe_url()本来就直接拒)。 - host 里出现
%或空白。 - 端口非法:不是数字,或者超出范围。
is_safe_url() 在 scheme 检查之后调用它,一旦判定有歧义,日志里只写原因,绝不写 URL——URL 本身可能带着凭据,写进日志等于换个地方泄漏。
兜底那一层为什么反而放宽了
is_always_blocked_url()(第 342 行)是给“跳过 is_safe_url() 的后端”用的:本地 Chromium、hybrid sidecar。它只拒绝那些能让客户端读到另一个 host 的东西:控制字符、缺少 // authority、authority 里的反斜杠。对百分号编码的 host,它按解码后的值判断,所以 http://%31%36%39.254.169.254/ 会被拦下来。
而非法端口、host 里的空白、userinfo 这几样,不会改变客户端最终连的 host,所以兜底层故意忽略它们。原因很实际:browser_exec 代码里那些模板字面量——f"http://localhost:{port}/"、"http://localhost:%d/"、JS 里的 `http://localhost:${port}/api`——靠的就是这类写法,兜底层要是也跟着严格,本地浏览器后端当场就不能用了。严格留给 is_safe_url(),兜底层只守住“host 会被读歪”这一条。
还有一个容易被忽略的细节:两个函数都从“检查控制字符时用的那个已经去过空白的字符串”里取 host,再拿 IDNA 编码后的 host 去和元数据地址比较,并且把 U+3002、U+FF0E、U+FF61 三个长得像句号的字符映射成 .。结果是 http://169.254.169.254。/、全角或半角点号拼出来的形式、以及全角数字,都会被判成 169.254.169.254——即使 DNS 解析失败、即使配了代理,也一样。
非 http(s) 的 URL(about:、data: 这类)不受这次改动影响。
顺手把两个概念讲明白
SSRF,服务端请求伪造。你让服务端去访问一个 URL,攻击者通过构造 URL,让服务端替他访问那些他自己够不着、但服务端够得着的地方:本机端口、内网管理后台、云平台的元数据接口。你的服务就成了他的跳板,也是他进入内网的一把钥匙。这类问题难防的地方在于——请求确实是从内网发出去的,防火墙不会拦,因为发起方本来就是“自己人”。
169.254.169.254 是云厂商放在每台实例上的元数据服务地址(link-local 保留地址)。它提供的不只是主机名和区域,通常还有实例级别的临时凭据。谁能诱使服务端请求这个地址,谁就可能拿到那台机器的云身份——拿到身份之后能做什么,取决于这台实例的角色权限。所以它在这套代码里被单列成“任何情况下都不许碰”的地址,和最严格的那条路径分开处理。
两件事叠在一起就是这次的场景:一个能够说服校验“我去的是公网”的 URL,一个会被客户端读成内网或元数据的 host。
你需要做什么
- 保持更新:
hermes update。这次改动在 10 月 27 日合入main,PR 里也明确写了没有关联 issue——它不是某个已知在野利用的补丁,而是一次主动收紧。 - 理解这次的语义:歧义即拒绝。 如果你有自动化流程专门构造这种“聪明”的 URL,升级后它会被拒。这不是回归,是有意为之;正常写法完全不受影响。
- 看待同类问题的方式可以一起调整。 上次 A2A 端口那道“回环地址不是安全边界”的修正,和这次是同一个思路:安全判断必须和真正的执行方看同一件事,否则判断本身就是漏洞。
- 如果你关心 agent 能扩展出去的那几个面,MCP 目录的安全指引和浏览器扩展的控制面值得一起读——前者是“你能装什么”,后者是“浏览器侧的执行权有多大”。这次的缺口正好落在这两者的交界处。
一句话总结这次的教训:校验函数读到的 host,必须就是客户端将要拨号的 host。做不到这一点,fail closed 是唯一诚实的答案。