Skip to content
Published on

CORS 错误,为什么该修的是服务器 — 浏览器策略的准确行为与那些错误解法

分享
Authors

引言 — 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.comhttps://api.example.com是不同的源,https://app.example.comhttp://app.example.com也是,https://app.example.comhttps://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-HeadersAccess-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 statusOPTIONS 返回了 401、404、405、500把 CORS 处理挪到认证中间件之前。注册 OPTIONS 路由
Redirect is not allowed for a preflight requestOPTIONS 的响应是 301 或 308让强制 HTTPS 重定向、末尾斜杠规范化不作用于 OPTIONS
Request header field authorization is not allowedAccess-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 allowedfile 协议、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 错误是浏览器在告诉你服务器配置有缺陷,而关掉这个信号和修好缺陷是两回事。你在前端代码、浏览器标志、扩展程序里找答案的时间越长,离正解就越远。要修的地方只有一处,就是生成响应头的那台服务器。