Skip to content

接口用命令行能通,浏览器里报跨域 ​

前端页面调用另一个域名下的接口,控制台报了跨域错误。后端用命令行工具请求同一个地址,返回完全正常,于是认为「接口没问题,是前端的事」。前端加上了请求头,问题没有解决,反而多出一种请求方法是 OPTIONS 的失败请求。

跨源限制是浏览器执行的规则,命令行工具不执行它,所以两边看到的结果不一样,而且两边都没有说错。要把这类问题讲清楚,得知道浏览器具体在哪一步做了什么:请求有没有发出去,接口有没有执行,页面脚本为什么读不到。

本文在真实的 Chromium 里把这几种情况跑出来,并与接口一侧实际收到的请求对照。实验见文末。

接口一侧页面脚本拿到的GET,响应没有跨源头到达并执行TypeError,读不到text/plain 的 POST,没有跨源头到达并执行TypeError,读不到application/json 的 POST,预检失败没有发出TypeError预检通过,响应带 Allow-Origin到达并执行读到响应带 Cookie,Allow-Origin 是 *到达并执行TypeError,读不到带 Cookie,写明源 + Allow-Credentials到达并执行读到响应第二行:页面报了跨域错误,接口那边的业务操作已经执行了一次用命令行直接请求时没有浏览器这一层,所以「命令行能通、浏览器里不通」响应头也受限:自定义的头要用 Access-Control-Expose-Headers 列出来,脚本才读得到
图 1 · 实测(Chromium 153):跨源限制是浏览器在响应回来之后决定「让不让页面脚本读」,不是在请求发出之前拦截。简单请求(GET、表单类型的 POST)照常发到接口并被执行,只是脚本读不到结果;只有需要预检的请求,才会在预检失败时不发出去。所以它保护的是「别的网站读不到你的数据」,不保护接口不被调用。

一、读不到,不等于没发出去 ​

页面在 http://localhost:8000,接口在 http://localhost:9000,端口不同,是两个源。接口的响应不带任何跨源相关的头。

text
不经过浏览器,用脚本直接请求同一个接口(没有任何跨源头):{"ok": true, "cookie": ""}
页面里 fetch 这个接口:脚本拿到 TypeError,读不到任何内容;接口收到这个 GET 1 次

浏览器把请求发出去了,接口处理了,响应也回来了。浏览器看到响应里没有允许这个源读取的声明,于是不把结果交给页面脚本,脚本拿到的是一个不带任何细节的 TypeError。具体原因只出现在浏览器的控制台里,页面脚本读不到。

同源策略限制的是「页面脚本读取别的源的响应」,不是「向别的源发请求」。 这一点对写操作尤其重要:

text
页面里发一个 text/plain 的 POST 到 /transfer(没有跨源头):脚本拿到 TypeError,读不到任何内容;接口收到 POST 1 次,转账次数 = 1

页面报了跨域错误,接口那边的操作已经执行了。所以跨源限制不能当作接口的访问控制:接口是否允许被调用,要靠身份认证、防跨站请求伪造的令牌、Cookie 的 SameSite 属性,这些是另一层的事。

二、预检:有些请求会先问一句 ​

把请求体的类型换成 JSON:

text
换成 application/json(接口不处理 OPTIONS):脚本拿到 TypeError,读不到任何内容;接口收到 OPTIONS 1 次、POST 0 次

这一次 POST 没有发出去。浏览器先发了一个 OPTIONS 请求(预检),问接口是否允许这个源用这种方法、带这些头来请求;接口没有正确回答,浏览器就不发真正的请求。

哪些请求需要预检,规则大致是:传统的表单能发出来的请求不需要,超出这个范围的需要。

  • 不需要预检:GET、HEAD,以及内容类型是表单的三种(application/x-www-form-urlencoded、multipart/form-data、text/plain)、不带自定义头的 POST。
  • 需要预检:其他方法(PUT、DELETE、PATCH);Content-Type: application/json;带了 Authorization 或任何自定义的请求头。

这就是开头「加了请求头反而多出一个 OPTIONS 失败」的原因:加头把一个简单请求变成了需要预检的请求,而接口(或者它前面的网关、鉴权过滤器)没有处理 OPTIONS。常见的具体情况是鉴权过滤器对 OPTIONS 也要求带令牌,而预检请求按规定不带任何凭据,于是返回 401。

接口正确应答预检之后:

text
接口正确应答预检并带上跨源头:读到响应(状态 200),X-Request-Id = null;接口收到 OPTIONS 1 次、POST 1 次

预检可以缓存,不必每次都问:

text
预检响应带 Access-Control-Max-Age: 600,连续发两次 POST:接口收到 OPTIONS 1 次、POST 2 次

不设置时浏览器的默认缓存时间很短,每个请求前面都多一次往返。

默认情况下跨源请求不带 Cookie。要带上,请求一侧要声明 credentials: 'include',响应一侧的要求也随之变严:

text
请求带上 Cookie(credentials: include),响应是 Allow-Origin: *:脚本拿到 TypeError,读不到任何内容
响应写明了源,但没有 Allow-Credentials:脚本拿到 TypeError,读不到任何内容
响应写明了源,并带 Allow-Credentials: true:读到响应(状态 200),X-Request-Id = null
上面三次请求,接口都收到了 = True

带凭据时,Access-Control-Allow-Origin 不能是 *,必须写出具体的源,并且要有 Access-Control-Allow-Credentials: true。这条规则是为了防止一个「对所有人开放」的配置把登录用户的数据暴露给任意网站。

由此引出一种危险的写法:为了「既支持所有来源又支持 Cookie」,把请求里的 Origin 原样写回 Access-Control-Allow-Origin。这等于对所有网站开放带登录态的读取,比 * 更糟。允许的源应当来自一份明确的白名单;按请求返回不同的源时,响应还要带 Vary: Origin,否则中间的缓存会把给一个源的响应发给另一个源。

实验里页面和接口都在 localhost 上,只是端口不同,属于同一个站点,所以 Cookie 能被带上。真正跨站点时,Cookie 还受 SameSite 属性控制,默认情况下不会在跨站的 fetch 请求里发送,这是独立于跨源响应头的另一道限制。

四、能读到响应,不等于能读到所有的头 ​

text
响应带 Access-Control-Allow-Origin: *:读到响应(状态 200),X-Request-Id = null
响应另外带 Access-Control-Expose-Headers: X-Request-Id:读到响应(状态 200),X-Request-Id = req-42

跨源响应里,脚本默认只能读到少数几个基础的响应头。分页总数、请求标识、限流剩余次数这类自定义的头,要在 Access-Control-Expose-Headers 里列出来。现象是开发者工具的网络面板里明明看得到这个头,代码里读出来却是 null。

五、排查顺序 ​

  1. 看浏览器控制台的报错原文,它会指出是哪一个头缺失或不匹配。页面脚本里的 TypeError 不包含这些信息。
  2. 看网络面板里有没有 OPTIONS 请求,它的状态码是什么。OPTIONS 失败,问题在预检;没有 OPTIONS 而请求本身有响应,问题在响应头。
  3. 确认响应头是谁加的。应用、网关、CDN 都可能加,重复出现两个 Access-Control-Allow-Origin 同样会失败;出错时的响应(4xx、5xx)经常由另一段代码生成,没有带上这些头,于是真正的错误被一个跨域错误盖住。
  4. 用命令行复现时带上 Origin 头,并单独发一次 OPTIONS,看到的才是浏览器会看到的响应头。

开发环境里通过同源的代理转发接口请求可以绕开这些问题,但生产环境的部署方式不同的话,问题会在上线时才出现。

六、检查清单 ​

  • 允许的源来自白名单,不原样回写请求里的 Origin;按源返回不同结果时带 Vary: Origin。
  • OPTIONS 请求不经过鉴权,能被正确应答;设置了合理的 Access-Control-Max-Age。
  • 需要 Cookie 的接口同时满足:具体的源、Allow-Credentials: true、Cookie 的 SameSite 设置与部署方式匹配。
  • 前端要读的自定义响应头已经列在 Expose-Headers 里。
  • 错误响应也带跨源头。
  • 跨源配置不被当作访问控制;写操作有独立的防护。

以上是 Chromium 153 的行为,规则本身由 Fetch 标准定义,各浏览器在预检缓存上限等细节上有差别。


配套实验

  • codesphere-labs/engineering/cors-browser-behavior:Playwright 1.63.0 驱动的 Chromium 153,页面与接口是容器内不同端口的两个本地服务:没有跨源头时的 GET 与 POST、预检、带 Cookie 的三种响应头组合、可读的响应头、预检缓存;页面脚本拿到的结果与接口实际收到的请求逐项对照(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权