三个手工维护的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网关团队在自己的生产环境中第四次遇到同样的问题,也是这篇文章的由来。

CORS的三个列表各自管什么
CORS由浏览器强制执行,服务器不参与。三个响应头回答三个不同的问题,每个都是一份人工维护的列表:
| 响应头 | 回答的问题 | 条目缺失的后果 |
|---|---|---|
| Access-Control-Allow-Headers | 浏览器可以发送哪些请求头? | 预检失败,调用根本不会发出 |
| Access-Control-Expose-Headers | JavaScript可以读取哪些响应头? | 调用成功,但客户端看不到价格、收据或退款状态 |
| 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名和路由路径均来自其生产配置,不涉及任何客户数据。
相关推荐

一句提示词引发的全球PHP安全风暴:AI是救星还是隐患?
一句AI提示词竟引发全球PHP安全危机,维基百科、互联网档案馆受波及。本文梳理事件脉络,解析OpenAI与亚马逊的争议,探讨AI时代漏洞发现门槛降低带来的安全悖论与开发者应对策略。

当AI被拿走:依赖捷径如何削弱你的解题韧性
一项涉及1,222人的研究发现:使用AI答题后,一旦AI被移除,人们放弃解题的比例高于从未使用AI的对照组。文章分析AI依赖对学习韧性和元认知的影响,并探讨AI学习工具的设计启示。

Carta联手NewView:让创业公司员工提前套现股权
Carta 与 NewView Capital 合作推出新计划,简化创业公司 tender offer 流程,帮助员工提前出售股权套现。本文分析该合作的核心机制、员工股权流动性痛点及对创业公司股权激励的影响。