[控场AI]
· 8 分钟阅读· 4,200 字

三个手工维护的CORS列表,四次遗漏的教训

三个手工维护的CORS列表,四次遗漏的教训

浏览器Agent因CORS头列表遗漏而静默失败,须从代码推导列表并机械校验。

文章从minia2a网关在四个月内四次遭遇的CORS故障出发,指出浏览器端Agent的付费调用之所以「服务器日志空白」,是因为预检请求在缺失的Header条目上就失败了。作者梳理了CORS三个手工维护列表(Allow-Headers、Expose-Headers、Allow-Methods)各自管辖的问题与失败后果,强调这类失败不会在服务端留下任何痕迹,健康检查始终为绿。由于x402与MCP把价格、收据、试用身份等关键信息放在Header里,活跃协议的持续演进让手工列表不断漂移。持久方案是停止信任列表,用grep从写入与读取Header的代码中推导总体,将每个名字归入「已列出/仅同源/非客户端信号/已知开放」四类,并用带反向断言的fixture测试防止表格朝任一方向撒谎;最后用OPTIONS预检验证线上二进制与源码一致。

浏览器端Agent发起付费调用,服务器日志却一片空白——没有402,没有400,没有超时。请求根本没离开浏览器:预检请求在一个缺失于列表的Header名上失败了。这是minia2a网关团队在自己的生产环境中第四次遇到同样的问题,也是这篇文章的由来。

rss source: Three Hand-Maintained CORS Lists, Four Misses

CORS的三个列表各自管什么

CORS由浏览器强制执行,服务器不参与。三个响应头回答三个不同的问题,每个都是一份人工维护的列表:

响应头回答的问题条目缺失的后果
Access-Control-Allow-Headers浏览器可以发送哪些请求头?预检失败,调用根本不会发出
Access-Control-Expose-HeadersJavaScript可以读取哪些响应头?调用成功,但客户端看不到价格、收据或退款状态
Access-Control-Allow-Methods浏览器可以使用哪些HTTP方法?PUT/PATCH/DELETE在预检阶段失败

这三类失败都不会在服务端产生错误。这正是问题的核心:服务器要么收到一个格式完好的请求,要么什么都收不到,因此所有服务端健康检查始终是绿的。浏览器控制台里那句“Request header field X is not allowed by Access-Control-Allow-Headers”,是唯一能看见的报警,而看到它的人往往不是维护列表的人。

预检请求(preflight) 是浏览器在发送「非简单请求」前自动发出的一次 OPTIONS 探测,用来向服务器确认:这个源、这个方法、这些请求头是否被允许。所谓非简单请求,指使用了 PUT/DELETE 等方法、自定义请求头(如 X-Payer-Signature)、或非标准 Content-Type 的请求。浏览器会在预检中通过 Access-Control-Request-Method 和 Access-Control-Request-Headers 声明「我接下来想发什么」,服务器则用 Access-Control-Allow-Methods、Access-Control-Allow-Headers 回应「我允许什么」。只要有一项对不上,浏览器就会在真正请求发出之前终止流程——这也是文中「服务器日志一片空白」的根源:被拦截的是预检,业务请求从未到达。对带自定义头的 Agent 调用而言,预检几乎必然触发,因此它是整条链路上最先、也最容易被遗漏挡住的关卡。

同源策略 是 CORS 存在的前提:浏览器默认禁止一个源的脚本读取另一个源的响应。CORS 并非放松这一限制的服务器机制,而是服务器「主动声明愿意被跨源访问」的协议层;执行者始终是浏览器,服务器只是配合应答。这解释了一个反直觉现象——服务器端代码、单元测试、curl 全部正常,只有真实浏览器里的调用失败。文中强调「CORS 由浏览器强制执行,服务器不参与」,正是提醒排查时不能只看服务端:服务端要观察到 CORS 相关问题,只有在它确实收到了预检并有机会记录时才行,而多数网关并不会为预检单独打日志。

为什么Agent API特别容易踩坑

x402和MCP协议把有趣的部分都放在Header里:价格报价是PAYMENT-REQUIRED,收据是PAYMENT-RESPONSE,试用身份是签名加时间戳加agent id,异步任务是token。每一个都必须出现在列表里——而一个仍在演进中的协议会持续添加新的Header。

服务端到服务端的Agent对此毫无感知,浏览器端Agent则会立刻暴露问题:预检请求在402挑战尚未取回之前就已经失败了。协议越活跃,手工列表的维护负担越重,遗漏的概率也越高。

x402 是一套基于 HTTP 402 Payment Required 状态码的付费调用协议:客户端首次请求时服务器返回 402 并附带价格条款(如 PAYMENT-REQUIRED 头),客户端完成支付后携带支付凭证重试,服务器再以 PAYMENT-RESPONSE 头返回收据。它的特点是把协商信息放进 HTTP 头而非请求体,因此对 CORS 的 Expose-Headers 与 Allow-Headers 高度敏感。MCP(Model Context Protocol) 则是面向模型与工具/数据源之间交互的协议,常以请求头承载身份、会话与任务 token。两者共同的工程后果是:协议的每次演进都可能新增 Header,而 CORS 列表是静态的人工白名单,二者天然存在时间差。服务端到服务端的 Agent 直接构造 HTTP 请求,绕过浏览器,因此完全感知不到白名单的存在;一旦同一套 Agent 逻辑搬进浏览器(或由页面内脚本调用网关),缺失的条目就会在预检阶段被放大为彻底失败。协议越活跃、Header 越多,这种「代码已用、列表未加」的漂移就越频繁。

四次遗漏的完整记录

minia2a团队公开了四次事故的日期和具体缺失项:

  • 2026-10-02:响应列表遗漏PAYMENT-REQUIRED和PAYMENT-RESPONSE。浏览器客户端能拿到402,却无法解析可接受的支付条款。
  • 2026-10-14:请求列表遗漏X-Payer-Signature。预检在402挑战被取回之前就失败了。
  • 2026-10-17:请求列表遗漏X-Trial-Identity。浏览器客户端退回到把钱包地址放进查询字符串——正是这个Header当初被引入时要消除的参数冲突。
  • 2026-10-18:一次三连击。X-Minia2a-Refund不在响应列表,X-Agent-ID不在请求列表(而它明明写在自己发布的quickstart里),PUT不在方法列表,尽管路由已经注册。

每次修复都只是往列表里加一个词。真正值得警惕的是:每次修复之后,同一类问题都会复发,因为列表与生成Header的代码之间没有任何连接。

用普查代替修补

持久的修复方案是停止信任列表,转而从发出或读取Header的代码中推导出总体。在minia2a的网关上,这意味着三个grep扫描:

# 1. 代码写入的响应头
grep -rhoE 'c\.Header\("[A-Za-z0-9-]+"' internal/httpapi/

# 2. 代码读取的请求头(字面量,以及解析后的常量名)
grep -rnE '(c\.GetHeader|Header\.Get)\(' internal/

# 3. 注册的HTTP方法
grep -rhoE '\.(GET|POST|PUT|PATCH|DELETE)\(' internal/httpapi/router.go

这个普查本身也很容易像列表一样出错。第一版只匹配直接的c.Header("…")调用,而三个试用相关Header——X-Trial-Remaining、X-Trial-Max、X-Trial-Almost-Exhausted——是从一个返回map[string]string的辅助函数里循环发出的。三者都写进了文档,却对检查不可见,检查结果还打印了clean。现在的普查会读取map字面量的键和索引赋值,并打印每一个c.Header(variable)的位置,让动态边缘可见,而不是被假设掉。

接下来把每个面向客户端的名字归入恰好一个桶:已列出、仅同源(附无需列表条目的理由)、非客户端信号。任何未分类的名字都会让检查失败。故意缺失的名字进入第四个桶“已知开放”,这个桶带反向断言:当该名字出现在实时列表中的那一天,检查会失败,直到条目被删除。否则这张表会朝另一个方向撒谎。

还有一个值得保留的控制项:用fixture而不是只对生产环境跑判定器。minia2a的测试集有17个用例,包括“一个已知开放的条目现在被允许了必须失败”和“一个曾在实时列表中而后消失的Header必须失败”。没有反向用例,普查只能报告过去。

确认你读的源码就是正在服务的二进制

读源码只是一半的工作。编译好的二进制可能落后于源码:有人改了中间件,没人重新构建,所有静态检查照样通过。一个预检请求就能回答这个问题:

curl -s -D - -o /dev/null -X OPTIONS https://minia2a.uk/x402/gas \
  -H 'Origin: https://minia2a.uk' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: X-Agent-ID'

响应是204加上三个响应头。把这三个列表和源码里的对比。如果不同,二进制就比你正在读的代码树旧。对minia2a而言,同一个探针也成了每次修复的验收测试:发送你认为被允许的Header,读取回显的允许列表,不再猜测。

通用规则

手工维护的列表是运行时无法替你检查的承诺。Agent发送或读取的每一个Header都是API表面的一部分,每一个列表条目都是表面与承诺可能静默漂移的地方。从代码推导总体,分类每个成员,并在已知缺口关闭时让表失败——服务端永远看不到的那些失败,才值得一个机械化的检查。

据minia2a披露,文中所述检查针对其已部署网关源码和第一方预检运行,Header名和路由路径均来自其生产配置,不涉及任何客户数据。

#CORS#Access-Control-Allow-Headers#预检请求#x402#MCP协议#浏览器Agent#API安全#Header管理
分享:

相关推荐