127.0.0.1 不是安全边界:Hermes 的 A2A 端口学会了拒绝浏览器


你照着文档把 A2A 打开,端口 9900,绑定 127.0.0.1,没有配 token——因为“只有本机能访问”听起来已经够安全了。第二天你在浏览器里随手打开一个陌生页面,页面里一段脚本往 http://127.0.0.1:9900/ 发了个请求。你以为它会被浏览器拦住,其实不会:从浏览器的角度看,那个页面和自己发起的请求是同一台机器上的两个端口,它当然发得出去。

问题从来不是“谁能连上这个端口”,而是“谁能让你浏览器替他发请求“。10 月 27 日合入的 PR #145310 把这道门补上了:在无 token 模式下,A2A 监听现在会拒绝浏览器发起的 POST,并且对每一条路由都校验 Host。

先弄清楚默认模式到底信任了什么

Hermes 的 A2A 平台有两个方向:你的 agent 可以调用别的 A2A agent(出站工具集,默认关闭),别的 agent 也可以把任务发给你的 Hermes(入站监听)。入站监听的默认姿势是这样的:

gateway:
  platforms:
    a2a:
      enabled: true
      extra:
        port: 9900

没配 A2A_BEARER_TOKEN 也没有 A2A_PEER_TOKENS 的时候,安全模型是“没有 token ⇒ 只听本机“:服务绑在 127.0.0.1,要对外暴露必须先设 token,再显式设 A2A_HOST。这个设计在 CLI 与 agent 的世界里是对的——本机进程之间的信任是合理的。

它漏掉了一类进程:浏览器。浏览器不是“你信任的本机程序”,它是加载了全世界网页的沙箱。任何网页都能向 127.0.0.1:9900 发请求,只要它是“简单请求”,连预检(preflight)都不会触发。

第一道门:拒绝浏览器发起的 POST

新的判断在 plugins/platforms/a2a/security.py 里,函数名很直白:browser_post_refusal()。无 token 模式下,以下三种请求直接 403:

  • 带了 Origin 头——正常 agent 客户端和 SDK 不会带这个头,浏览器一定会带;
  • Sec-Fetch-Site 不是 none 或 same-origin——这是浏览器自己标注的“请求来自哪个站点”,跨站请求一目了然;
  • Content-Type 不是 application/json(含 +json 后缀)——网页不需要预检就能发出去的只有那几种简单类型(表单、纯文本),而 A2A 说的是 JSON-RPC,本来就该是 JSON。

第三条其实是最巧的一刀:它不依赖浏览器愿不愿意老实交代来源,而是要求请求“长得像 A2A”。一个网页想伪装成 JSON 请求就得触发预检,而预检会被拒。Hermes 自己的 A2A 客户端本来就发 application/json,也不带这些浏览器头,所以两个 Hermes 之间互调完全不受影响。

第二道门:Host 必须是回环名

第二道门防的是另一类攻击:DNS 重绑定(DNS rebinding)。说人话:攻击者控制一个域名,先把它解析到自己的服务器让你打开页面,然后把这条 DNS 记录的 TTL 拉到极短,页面里的脚本过一会儿再请求同一个域名——这次解析到 127.0.0.1。浏览器认为这还是“同一个站点”,于是同源策略不再保护你,脚本可以直接读到本机服务返回的内容。

补法是在 A2ARequestHandler.parse_request() 里,任何路由执行之前先校验 Host:只接受回环字面量(localhost、127.x.x.x、[::1]、localhost.)或者 A2A_PUBLIC_URL 里配置的那个主机名,否则 403,连 /health 和 Agent Card 都不给。

为什么允许 A2A_PUBLIC_URL?因为真实部署里前面常有一个反向代理,它转发时会把自己收到的主机名带过来。A2A_PUBLIC_URL 是你自己声明“我这个服务对外叫这个名字”,所以它被信任;文档也顺手提醒了一句:只填你控制的域名。

token 模式的监听不受这两道门影响——有 token 时就该由 token 决定谁能进来,这是刻意的分工,不是遗漏。

怎么验证你的实例是不是已经关好了

升级到包含这次改动的版本后,从另一台机器或另一个进程试三件事。第一,正常取 Agent Card,应该成功:

curl -s http://your-host:9900/.well-known/agent-card.json | head -20

第二,伪造一个外站 Host,应该拿到 403:

curl -s -o /dev/null -w '%{http_code}\n' \
  -H 'Host: evil.example' \
  http://127.0.0.1:9900/.well-known/agent-card.json

第三,带 Origin 的 POST,也应该 403:

curl -s -o /dev/null -w '%{http_code}\n' \
  -X POST http://127.0.0.1:9900/ \
  -H 'Origin: https://evil.example' \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"SendMessage","params":{}}'

如果你在浏览器控制台里看到的是 CORS 报错而不是 403,那说明请求被浏览器自己拦了——这同样是好消息,只是拦在了另一层。

顺带说清三件容易踩的事

第一,无 token 不等于没有权限模型。 除了这次的 Host 与来源校验,A2A 还有一整套:A2A_TRUSTED_PEERS 白名单、每身份的限流(A2A_RATE_LIMIT,默认每身份每分钟 60 次)、入站文本的提示注入过滤、出站凭据打码、以及 ~/.hermes/a2a_audit.jsonl 审计日志。还有一个容易忽略的细节:如果你把监听绑到非回环地址却没有配 A2A_TRUSTED_PEERS,每一次派发都会被 403 拒绝——这是 fail-closed,不是 bug。

第二,跨机器场景请直接用 token。 两件事要一起做:设 A2A_PEER_TOKENS(alice:tok1,bob:tok2 这种每对端一个凭据,比共享 token 好排查),然后才设 A2A_HOST。绑定地址本身不是权限,token 才是;关于“暴露面越小越好”这件事,dashboard 登录加固那次讲过同一套逻辑。

第三,端口不要随手改。 A2A_PUBLIC_URL 一旦填错,Agent Card 会宣传一个对方连不上的地址,症状是“对方一直超时”而不是报错。命令与配置的完整清单在 gateway 命令参考里,第一次装建议按 安装指引 走一遍再看 A2A 章节。

现在能用吗

这个改动在 10 月 27 日合入 main。v0.22.5 是 10 月 23 日发布的,所以哪怕当天的 canary 标签也不包含它——你可以对着 v0.22.5 的发版记录确认一下时间线。想要这两道门,等下一个版本,或者跟 main 走。

最后留一句判断标准,比任何配置都实用:只要一个端口能被你浏览器里的网页访问,它就不是“只有本机能访问”,而是“全世界都能通过你的浏览器访问”。 本地服务的安全模型,得按这个前提来设计。