凌晨两点,你在本地起了 Create React App,前端跑在 3000 端口,后端 API 在 8000 端口,写完第一个 fetch() 调用,Chrome 的 Console 里跳出一行红字:「has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present on the requested resource。」你打开 Google 搜”CORS error fix”,第一条 Stack Overflow 告诉你「在后端加 Access-Control-Allow-Origin: *」,你照做了,红字消失,世界和平。至于这行配置到底做了什么,服务器端那行 header 为什么能控制浏览器端的行为,你不太确定,也不太想深究——毕竟代码已经能跑了。这是地球上每一秒都在发生的场景。二十年过去了,CORS 仍然是 Web 开发中最容易被「修好但没弄懂」的安全机制。2026 年 6 月,一篇 2019 年的老文章「Developers don’t understand CORS」在 Hacker News 上重新翻红到 353 分、251 条评论,评论区最高票的两条评论直接对立,底下的子线程打了二百层楼,双方你来我往、引经据典,最后谁也没说服谁。更耐人寻味的是,被评论者揪出来批判的对象正是这篇科普文本身——「Even TFA (The F*ing Article) seemingly doesn’t understand CORS。」写科普的人,自己也在混淆。
CORS 到底在干嘛
要理解这场争吵,得先回到最基础的问题上:CORS 是做什么的。笔者从过往的技术文献和法律规范中梳理出的图景大致如下——CORS(Cross-Origin Resource Sharing,跨域资源共享)是浏览器实现的一套协议,用于在特定条件下放松同源策略(Same-Origin Policy,SOP)的限制。对,放松,不是收紧。SOP 是浏览器内建的安全基座:默认情况下,example.com 加载的 JavaScript 不能向 bank.com 发请求并读取响应。这个默认策略保护的是用户——如果你登录了网银,浏览器里存着 authentication cookie,你打开的任何其他网站都不能偷偷读取你网银的数据。CORS 的作用是给服务器一个机制说「某些其他来源可以读我的响应」,通过 Access-Control-Allow-Origin 等 response header 来实现。
命名本身就暗示了它的定位:它是关于共享(Sharing),不是关于封锁。但这个命名恰好是大量混淆的源头——当开发者看到 Console 报错「blocked by CORS」,直觉反应是「CORS 在阻止我」。实际上阻止请求的是 SOP,CORS 是在你尝试跨域时浏览器检查服务器是否授权的一套流程。如果服务器没有授权,浏览器的行为是「不许读响应」(且对非简单请求,干脆不许发),而这个「不许」被归因到了 CORS 这个名字下。名字和行为的错位,是认知债务的第一笔本金。
两种「对」,打了两百层楼
HN 讨论中最核心的分歧可以浓缩成两条评论的对峙。第一条来自用户 muvlon(最高票,17 小时前发布),大意是:这篇文章本身就没搞懂 CORS——CORS 不会阻止请求,它只是放松默认限制。来自任何网站的 JavaScript 都能向你的 localhost:19421 发请求,Access-Control-Allow-Origin 头只是决定能不能读响应,请求本身无论如何都会发出去。第二条来自用户 stymaar(12 小时前),直接反驳:不,你说得不对——对于 GET 这样的安全方法,请求确实会发出去,但 GET 理应是幂等的,读不到响应就是全部保护。对于非幂等请求,浏览器会先发 OPTIONS preflight,如果 preflight 响应里没有正确的 CORS header,浏览器根本不会发实际请求。
两个人都不是胡说。他们各自在自己定义的场景里是对的。muvlon 涵盖的场景是「简单请求」(simple requests)——不触发 preflight 的那一类:GET、HEAD、POST(Content-Type 为 application/x-www-form-urlencoded、multipart/form-data 或 text/plain),以及一组安全的 standard header。这些请求会发出去,服务器会处理,响应也会回来,浏览器只是不把响应交给 JavaScript。stymaar 描绘的是「非简单请求」——PUT、DELETE、PATCH、Content-Type 为 application/json 的 POST、携带 Authorization header 的请求等。这些请求会先触发 OPTIONS preflight,preflight 通不过,实际请求就不会发出。
工程上的判断是:两派在各自的上下文里都有道理,但各自把道理讲成了全域真理。muvlon 那句「the requests happen in any case」作为全域陈述是错的——对于非简单请求,preflight 失败确实会阻止请求发送。stymaar 用 preflight 机制来为原文件者辩护的方式也有遗漏——他忽略了 Zoom 的场景涉及的是本地 localhost 服务器,攻击面来自 GET 类简单请求,原文件者的表述「only Javascript running on the zoom.us domain can talk to the localhost webserver」确实不够精确:任何网站都能「talk to」这个 localhost 服务器(发简单请求),只有被授权的网站才能读到响应。如果 localhost 服务器把危险操作挂在 GET 端点上,Access-Control-Allow-Origin 挡不住请求,只能挡住响应被读取。而一个破坏性 GET 请求,发出去了就是发出去了。
Preflight 的微积分
Preflight 机制本身藏着更多容易被忽略的细节。HN 讨论中有人指出,POST 请求如果 Content-Type 设为 text/plain,就能绕过 preflight——因为 text/plain 在「简单请求」的白名单里。攻击者可以构造一个这样的 form:
<form action="https://victim.com/api" method="POST" enctype="text/plain">
<input name='{"key":"value", "ignore":"' value='"}'>
</form>
发送到服务器端的内容会变成 {"key":"value", "ignore":"="},看起来像畸形 JSON,但如果后端不严格检查 Content-Type header 就直接 JSON.parse body,这个请求就能穿透 preflight 屏障。一位声称在多次渗透测试中成功利用此技术的用户评论区现身说法。这不是纯粹的理论推演——只要服务器不做 Content-Type 校验,text/plain 和 multipart/form-data 的简单 POST 就能承载任意 payload。类似地,如果 Content-Type 检查只做了前缀匹配而非精确匹配,multipart/form-data; boundary=application/json 这样的 header 值也能绕过。
这些边角案例说明了一件事:CORS 的安全模型既不能被简化成「请求能否到达服务器」,也不能被简化成「响应能否被读取」——它是一个分叉树,简单请求和非简单请求走不同的路径,不同路径上的保护边界不相同。把任何一条路径上的规则概括为全域规则,就会产生认知偏差。而这个分叉树还在不断长出新枝——Sec-Fetch-* headers、SameSite cookie 属性、Cross-Origin-Embedder-Policy、Cross-Origin-Opener-Policy,每一层都在 CORS 上方叠加新的语义,让原本就不简单的心智模型更加难以把握。
为什么连科普作者都会搞错
Chris Foster 的原文章发表于 2019 年 7 月,核心案例是 Zoom 的本地 webserver 漏洞。Zoom 在用户机器上运行了一个监听 localhost:19421 的 webserver,当用户点击 Zoom 链接时,网页向这个本地服务器发请求打开原生客户端。为了绕过 CORS,Zoom 没有用 AJAX,而是加载了一张图片,通过图片的尺寸来编码状态码。Foster 的建议是:这个本地 webserver 应该设置 Access-Control-Allow-Origin: https://zoom.us,这样「只有 zoom.us 上的 JavaScript 才能与本地服务器通信」。
Foster 的前半句判断(Zoom 做法不安全)是对的,但后半句的表述(「只有 zoom.us 才能通信」)在技术上有歧义。严格来说,Access-Control-Allow-Origin 不能阻止其他网站向 localhost 发起简单请求,只能阻止其他网站的 JavaScript 读取响应。如果本地 webserver 在 GET 端点上暴露了敏感操作,仅靠 CORS header 不够。
但这不是 Foster 一个人的问题。整条 HN 评论线程都在用各种方式论述 CORS,而论述者之间的分歧不比他们与 Foster 的分歧小。有人坚称 CORS 根本不能阻止任何请求;有人反驳说 preflight 就是用来阻止请求的;第三人跳出来指出 POST text/plain 和 form 请求绕过 preflight 的问题;第四个人补充说即使请求发出去了,没有 CORS header 也读不到响应,所以对 GET 类操作的保护是完整的——只要服务器不把写操作挂在 GET 上。每一层反驳都在暴露上一层的不完整,最终的结果是二百五十条评论后仍然没有形成共识。
笔者观察到一个模式:CORS 的认知困难不全是因为它复杂,还因为它要求开发者同时理解三件事才能正确建模——浏览器的 SOP 基座、CORS 作为 SOP 的放松机制、以及 HTTP 方法的安全性和幂等性约定。这三件事分别属于浏览器架构、Web 安全协议和 RESTful 设计三个领域,大多数开发者只熟悉其中的一到两个。当一个人只用「SOP + CORS」来建模,他容易得出「请求被阻止了」的结论(因为浏览器层面的整体效果看起像是这样)。当一个人只用「HTTP 语义」来建模,他看到的是服务器收到了请求并返回了响应,「请求明明发出去了」。两种建模在不同层面都是对的,但它们投射到同一个名词「CORS」上时产生冲突。
时代的裂缝
评论区有一条观察很有意思:这可能是代际问题。如果你是在 CORS 出现之前就开始做 Web 开发的程序员,你经历过只有 SOP、没有合法跨域请求的年代。你知道 JSONP 是怎么 hack 的,你知道 <img> 标签和 <script> 标签为什么能跨域而 XHR 不能。当 CORS 出现时,你看到的是 SOP 被打开了一扇门——它是解决方案。但如果你是在 CORS 已经存在之后才开始写 Web 应用,你遇到的第一个跨域报错就写着「blocked by CORS」,你的直觉是 CORS 在妨碍你工作——它是问题。
代际差异确实存在,但更深层的问题出在 CORS 的文档、教学和报错信息在设计上就暗含了认知偏向。浏览器的 Console 错误消息写的是「blocked by CORS」,而不是「blocked by Same-Origin Policy due to missing CORS authorization」。MDN 文档解释了完整的机制,但大多数开发者不会去读完整文档——他们搜到 Stack Overflow 上第一个能修好问题的答案就停了。HN 评论区里不止一人承认:「我每次遇到 CORS 问题都要重新学一遍,学完又忘。」一位自称 CTO 的评论者说,他所在公司的用户大量遇到 CORS 问题来寻求支持,而他的观察是:现在不需要真正理解了,因为 Claude 和 GPT 已经能修 CORS 错误了——把报错丢给 LLM 就行。另一位立刻反驳:他最近遇到的 CORS 错误穿透了 Claude、Copilot 和另一位 senior 工程师三道防线才被解决。如果连写科普文章的人和读科普文章的人都在打架,LLM 从混乱的训练数据中学到的答案又能可靠到哪去。
烂账不打算平
CORS 的设计者面对的是一个几乎不可能的任务:在兼容二十年 Web 历史包袱的同时,为浏览器的跨域交互提供一套安全的授权机制。HTML <form> 的跨域能力在 CORS 出现前就已经存在二十多年,简单地封死会 break 整个互联网。CORS 选择了一条折中路线:保持「简单请求」向后兼容,为「非简单请求」引入 preflight。这个选择在当时是务实的,但它把复杂性内化到了协议本体里——开发者必须理解哪些请求是简单的、哪些不是,哪些 header 是安全的、哪些不是,OPTIONS 请求为什么会出现以及它和实际请求是什么关系。二十年下来,又叠上了 SameSite、Sec-Fetch、COEP、COOP 等新层,复杂度只增不减。
笔者倾向于认为,CORS 的认知债务根源于 Web 平台自身演化方式——向后兼容是硬约束,分阶段演进是唯一可行的路径,而每阶段的妥协都会留下需要后续开发者额外学习的概念债务。这笔账很难平掉,因为它已经刻在浏览器和数十亿网页的 DNA 里了。
HN 那条评论线程或许成不了 CORS 问题的终点。但它是一个不错的截面——它展示了即使是技术社区里最关心这个话题的人,聚在一起激烈讨论了两天,也仍然无法就最基本的事实达成一致。如果连这群人都统一不了,期待普通开发者精准掌握 CORS 的每个细节,可能不太现实。
本文基于对 Chris Foster 原文及 Hacker News 讨论线程的技术分析撰写。笔者不是 CORS 规范的原始作者或浏览器引擎开发者,文中对技术机制的解读来自对公开标准文档和社区讨论的阅读与理解,可能存在偏差。如果你发现本文中的技术表述有误,请以 WHATWG Fetch Standard 和 MDN Web Docs 为准。