引言 — curl 能通,只有浏览器被挡
假设控制台里冒出了这么一段。
Access to fetch at 'https://api.example.com/v1/orders' from origin
'https://app.example.com' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.
大多数人做的第一件事,是在终端里把同一个请求复现一遍。
curl -i -X GET 'https://api.example.com/v1/orders' \
-H 'Origin: https://app.example.com' \
-H 'Authorization: Bearer eyJhbGciOi...'
HTTP/2 200
content-type: application/json; charset=utf-8
content-length: 1842
date: Sun, 26 Jul 2026 04:11:32 GMT
x-request-id: 9f1c2a44-3b8e-4d1a-9c77-2f0a1b6d4e55
{"items":[{"id":"ord_8812","total":49000}, ...]}
是 200。服务器收到了请求,放行了认证,查了数据库,返回了 JSON。并不是因为没有数据才失败的。响应里只是少了一行,access-control-allow-origin。
这一行的差别就说明了 CORS 的全部。而大多数人正是在这里得出第一个错误结论:既然是前端的问题,那就在前端修。前端没有可修的东西。是因为服务器没有表明许可,浏览器才拦下来的,而能够表明许可的主体只有服务器。
CORS 不是服务器安全,而是浏览器强制执行的放宽策略
同源策略是浏览器的默认行为。协议、主机、端口全都相同才算同源,别的源的响应脚本读不到。https://app.example.com和https://api.example.com是不同的源,https://app.example.com和http://app.example.com也是,https://app.example.com和https://app.example.com:8443同样是。
想一想没有这条策略会发生什么,它存在的理由就很清楚了。如果恶意站点能用 fetch 从你打开的标签页里取到https://mail.example.com/inbox并读出来,浏览器里残留的 Cookie 会自动被带上,于是能在登录状态下把整个邮箱都刮走。同源策略挡的就是这个。
CORS 是放松这条策略的装置,不是收紧它的装置。当服务器用响应头声明「来自这个源的脚本可以读我的响应」,浏览器就允许这个例外。由此推出三个事实。
第一,强制执行的主体是浏览器。curl、Postman、服务端 fetch、移动应用里的 HTTP 客户端都没有实现同源策略,因此与 CORS 无关。「用 CORS 保护 API」这句话是不成立的。真正的攻击者不用浏览器。
第二,被判断的对象是读取响应这个行为,而不是发送请求这个行为。不带预检的请求实际上会到达服务器并被执行。浏览器只是不把那个响应交给脚本而已。这个事实在后面谈 CSRF 时是决定性的。
第三,要修的地方永远是生成响应头的那一侧。那一侧如果是我们的服务器就我们来修,如果是别人的 API 就去请求对方,或者让请求经由我们自己的服务器。改浏览器设置只是在自己的浏览器里成立的自欺欺人。
预检发生的准确条件
浏览器并不会给每一个跨源请求都先发 OPTIONS。那些 HTML 表单很早以前就能发出的形态的请求,会照原样发出去。这类叫简单请求,条件是同时满足下面三条。
方法必须是 GET、HEAD、POST 之一。PUT、PATCH、DELETE 无条件触发预检。
手动设置的头必须全部落在许可清单之内。清单大致是 Accept、Accept-Language、Content-Language、Content-Type、Range。一加上 Authorization,预检就出现了。X-Requested-With、X-Trace-Id 这类自定义头也一样。这是实务中最常见的触发原因。
Content-Type 的值必须是 application/x-www-form-urlencoded、multipart/form-data、text/plain 之一。application/json 不在这个清单里。所以几乎所有 POST JSON 的现代 API 调用都会经历预检。
此外,如果给 XMLHttpRequestUpload 挂了事件监听器,或者用 ReadableStream 作为请求体,同样会产生预检。
真实的预检长这样。
OPTIONS /v1/orders HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization,content-type
预检没有请求体,不带 Cookie,也不需要通过服务器的认证。服务器该返回的是这样的响应。
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 7200
Vary: Origin
这里经常出问题的地方有两个。一个是认证中间件把 OPTIONS 请求也拦下来返回 401。预检只要不是 2xx 就按失败处理,所以 CORS 处理必须放在认证中间件之前。另一个是重定向。预检响应回来的是 301 或 308 时,浏览器不会跟随,直接判失败。从 HTTP 到 HTTPS 的重定向、补末尾斜杠的重定向都会撞上这一条。
Access-Control-Max-Age是浏览器缓存预检结果的时间。不过有上限。Chrome 会截到 7200 秒,Safari 比这短得多。写上 86400 然后指望一整天都不发 OPTIONS,是要落空的。
响应头的作用与常错的组合
Access-Control-Allow-Origin只能有一个值。用逗号列出多个源是无效的。要允许多个源,服务器必须查看请求的 Origin 头,只有在许可清单里时才把那个值原样回写。
Access-Control-Expose-Headers会扩大脚本可读的响应头范围。默认能读的只有 Cache-Control、Content-Language、Content-Length、Content-Type、Expires、Last-Modified、Pragma 这七个。如果你用 X-Total-Count 把分页总数发下去,前端却拿到 null,那几乎总是漏了这个头。它表现为服务器日志里头打得好好的、只有客户端看不见,因此找原因格外困难。
最常出错的组合是凭据与通配符。对于用credentials: 'include'带上 Cookie 的请求,适用下面这些规则。Access-Control-Allow-Origin不能是星号,Access-Control-Allow-Headers和Access-Control-Allow-Methods里的星号也失效,Access-Control-Expose-Headers里的星号同样失效。全部都得显式列出。这条规则是有理由的。一旦允许星号,任何站点都能读到用用户 Cookie 认证过的响应,那就等同于同源策略消失了。
所以下面这段代码很危险。
// 不要这么做 — 这等于允许所有源读取已认证的响应
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', req.headers.origin ?? '*')
res.setHeader('Access-Control-Allow-Credentials', 'true')
next()
})
把 Origin 原样反射回去,只是在形式上绕开了禁止通配符的规则,实质上就是允许了所有源。攻击者站点发一个 fetch,用户的会话 Cookie 就被带上,响应也被原样读走。必须用许可清单来检查。
const ALLOWED = new Set(['https://app.example.com', 'https://admin.example.com'])
app.use((req, res, next) => {
const origin = req.headers.origin
// 即使是未被许可的源,Vary 也始终要加上
res.setHeader('Vary', 'Origin')
if (origin && ALLOWED.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Access-Control-Allow-Credentials', 'true')
res.setHeader('Access-Control-Expose-Headers', 'X-Total-Count, RateLimit-Remaining')
}
if (req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE')
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type')
res.setHeader('Access-Control-Max-Age', '7200')
return res.status(204).end() // 在认证中间件之前就结束
}
next()
})
用正则表达式做许可清单时,要转义点号并锚定字符串结尾。为了整片放行子域而随手写的模式,会把https://example.com.attacker.io这样的值放进来。因为这个失误导致账号被接管的案例已经公开过好几次。可能的话,用字符串集合比较而不是正则表达式更安全。
Origin: null也不能放进许可清单。它是带 sandbox 属性的 iframe、本地文件以及某些重定向场景下会出现的值,而攻击者只要在自己页面上放一个 sandbox iframe,随时都能造出来。
漏掉 Vary 会污染缓存
响应头随 Origin 变化,却没有Vary: Origin,那么中间的 CDN 或反向代理只看 URL 就会复用响应。于是就出这种事故:来自 admin 源的请求所产生的响应进了缓存,下一次 app 源请求同一个 URL 时,出去的是带着Access-Control-Allow-Origin: https://admin.example.com的响应。app 那边报 CORS 错误,并且一直持续到缓存过期。
症状尤其难缠。复现不出来,刷新一下有时又好了,还只在特定区域的用户身上发生。反方向的事故也有。如果服务端不带 Origin 请求得到的响应被缓存了,存下来的就是完全没有 CORS 头的响应,之后所有浏览器请求都会被挡。
所以反射 Origin 的服务器必须无条件加上Vary: Origin。对未许可的源也要加。
按错误消息解读原因
浏览器控制台消息指出原因的精度比想象中高。以下是常见的几条。
| 控制台消息的关键短语 | 实际原因 | 该修的地方 |
|---|---|---|
| No Access-Control-Allow-Origin header is present | 服务器没发这个头。包括以 5xx 挂掉、没走到 CORS 中间件的情况 | 服务器响应头。先用 curl 确认状态码 |
| Response to preflight request does not have HTTP ok status | OPTIONS 返回了 401、404、405、500 | 把 CORS 处理挪到认证中间件之前。注册 OPTIONS 路由 |
| Redirect is not allowed for a preflight request | OPTIONS 的响应是 301 或 308 | 让强制 HTTPS 重定向、末尾斜杠规范化不作用于 OPTIONS |
| Request header field authorization is not allowed | Access-Control-Allow-Headers 里漏了该头 | 预检响应中的许可头清单 |
| Method PATCH is not allowed by Access-Control-Allow-Methods | 许可方法清单有遗漏 | 预检响应中的许可方法清单 |
| must not be the wildcard when credentials mode is include | 用了星号同时又发送 Cookie | 把源写成明确值。加上许可清单检查 |
| contains multiple values, but only one is allowed | 代理和应用各自加了一次这个头 | 整理成只在一处添加。通常是 nginx 和应用两边都配了 |
| Origin null is not allowed | file 协议、sandbox iframe、重定向之后的请求 | 使用本地开发服务器。不要把 null 放进许可清单 |
清单最上面那条最常见,也最容易被误读。这条消息可能意味着「服务器没配 CORS」,但相当多的情况下意味着「服务器以 500 挂掉了,根本没走到加这个头的中间件」。出现 CORS 错误时,一定要先在网络面板里看真实的状态码,或者用 curl 确认。把 500 误当成 CORS 问题而折腾几个小时,是很常见的事。
代理绕行 — 正当的情况与不正当的情况
只要做成同源,CORS 就根本不会发生。所以代理永远有效。问题在于,什么时候它是设计,什么时候它是回避。
有正当的情况。第一,第三方 API 不发 CORS 头,而我们又改不了那台服务器。第二,需要把 API 密钥藏起来的时候。下发到浏览器的密钥就是公开的密钥,因此这种情况下经由服务器不是绕行,而是唯一正确的结构。第三,我们已经在运营把多个后端归拢到同一个源之下的网关或 BFF。第四,开发环境里由 dev 服务器代理 API 的情况。
// vite.config.js — 开发时干脆做成同源
export default {
server: {
proxy: {
'/api': {
target: 'https://api-dev.example.com',
changeOrigin: true,
},
},
},
}
不正当的情况同样明确。明明是我们自己掌控的后端,只因为嫌加三行头麻烦就架个代理,等于永久地多加一层基础设施和一份延迟。在生产环境里使用公开的 CORS 代理服务,是把用户的令牌和数据流向第三方服务器,而那台服务器一挂,我们的服务也跟着挂。
mode: 'no-cors'也不是解法。错误消失了,但回来的是 opaque 响应,状态码和响应体都读不到。除非是把图片或脚本当作副作用加载,否则毫无用处,可偏偏因为错误没了,就容易误以为修好了。
关掉浏览器安全这条建议
一搜索必定排在前面的一条建议,是用--disable-web-security标志启动 Chrome。有三个理由说明它很糟。
那个配置文件下的所有标签页都会失去同源策略。开发时开着的其他标签页全部变得毫无防护。一旦成了习惯,你在平时用的浏览器里也会开这个标志。
你会在一个生产环境中并不存在的环境里做开发。凭据与通配符的组合、Vary 缺失、暴露头缺失这些问题会被全部遮住,然后在预发或生产上一次性爆发。
它只是把问题往后推,什么也没解决。反正上线前还是得改服务器的头,到那时还要连同期间积累的误解一起解开。
替代方案很简单。用 dev 服务器代理,或者把开发用的源加进服务器的许可清单。后者更好,因为它是走和生产相同的路径被验证的。
CORS 并不能挡住 CSRF
这是最危险的误解。「我们配了 CORS,所以别的站点调不了我们的 API」这句话是错的。CORS 挡的是读取响应,不是挡请求被执行。
假设攻击者的页面上有这样一个表单。
<!-- evil.example.com — CORS 完全挡不住这个请求 -->
<form action="https://bank.example.com/transfer" method="POST">
<input name="to" value="attacker" />
<input name="amount" value="1000000" />
</form>
<script>
document.forms[0].submit()
</script>
表单提交是 Content-Type 为 application/x-www-form-urlencoded 的 POST,因此属于简单请求。没有预检。浏览器带上 bank.example.com 的 Cookie 发出请求,服务器把它当作已登录用户的请求处理并执行了转账。攻击者读不到响应,但他不需要读。钱已经转走了。
真正挡住 CSRF 的是另外一些装置。
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/
SameSite=Lax不会把 Cookie 带到来自跨站的 POST 上。它也是现代浏览器的默认值,因此相当一部分经典 CSRF 已经被挡住了。不过要知道它是以站点为单位判断的。app.example.com 和 api.example.com 是不同的源但属于同一个站点,所以 SameSite 不会区分这两者。只要有一个子域被攻破,防御就没了。
所以改变状态的请求还要配合 CSRF 令牌。让客户端把服务器签发的值放在请求体或自定义头里发回来,由服务器核对。要求自定义头这件事本身就会强制预检,因此也算一层附带防御,但只靠它作为依据是很薄弱的。声明只接受 JSON、拒绝表单类 Content-Type,也是同样性质的辅助手段。
归纳起来,CORS 和 CSRF 是方向相反的两个问题。CORS 挡的是别人把你的数据读走,CSRF 防御挡的是别人以你的名义写入。配好了一边,并不会把另一边解决掉。
结语 — 浏览器告诉你的是服务器的问题
遇到 CORS 错误时的顺序是这样的。用 curl 发同样的请求,确认真实的状态码和响应头。是 500 就不是 CORS 问题,而是服务器错误。是 200 却没有头,那就是服务器的 CORS 配置问题。如果这个请求会走预检,就单独确认 OPTIONS 的响应。然后拿控制台消息里的关键短语去前面那张表里查。
CORS 错误是浏览器在告诉你服务器配置有缺陷,而关掉这个信号和修好缺陷是两回事。你在前端代码、浏览器标志、扩展程序里找答案的时间越长,离正解就越远。要修的地方只有一处,就是生成响应头的那台服务器。
현재 단락 (1/121)
假设控制台里冒出了这么一段。