接口用命令行能通,浏览器里报跨域
前端页面调用另一个域名下的接口,控制台报了跨域错误。后端用命令行工具请求同一个地址,返回完全正常,于是认为「接口没问题,是前端的事」。前端加上了请求头,问题没有解决,反而多出一种请求方法是 OPTIONS 的失败请求。
跨源限制是浏览器执行的规则,命令行工具不执行它,所以两边看到的结果不一样,而且两边都没有说错。要把这类问题讲清楚,得知道浏览器具体在哪一步做了什么:请求有没有发出去,接口有没有执行,页面脚本为什么读不到。
本文在真实的 Chromium 里把这几种情况跑出来,并与接口一侧实际收到的请求对照。实验见文末。
一、读不到,不等于没发出去
页面在 http://localhost:8000,接口在 http://localhost:9000,端口不同,是两个源。接口的响应不带任何跨源相关的头。
不经过浏览器,用脚本直接请求同一个接口(没有任何跨源头):{"ok": true, "cookie": ""}
页面里 fetch 这个接口:脚本拿到 TypeError,读不到任何内容;接口收到这个 GET 1 次浏览器把请求发出去了,接口处理了,响应也回来了。浏览器看到响应里没有允许这个源读取的声明,于是不把结果交给页面脚本,脚本拿到的是一个不带任何细节的 TypeError。具体原因只出现在浏览器的控制台里,页面脚本读不到。
同源策略限制的是「页面脚本读取别的源的响应」,不是「向别的源发请求」。 这一点对写操作尤其重要:
页面里发一个 text/plain 的 POST 到 /transfer(没有跨源头):脚本拿到 TypeError,读不到任何内容;接口收到 POST 1 次,转账次数 = 1页面报了跨域错误,接口那边的操作已经执行了。所以跨源限制不能当作接口的访问控制:接口是否允许被调用,要靠身份认证、防跨站请求伪造的令牌、Cookie 的 SameSite 属性,这些是另一层的事。
二、预检:有些请求会先问一句
把请求体的类型换成 JSON:
换成 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。
接口正确应答预检之后:
接口正确应答预检并带上跨源头:读到响应(状态 200),X-Request-Id = null;接口收到 OPTIONS 1 次、POST 1 次预检可以缓存,不必每次都问:
预检响应带 Access-Control-Max-Age: 600,连续发两次 POST:接口收到 OPTIONS 1 次、POST 2 次不设置时浏览器的默认缓存时间很短,每个请求前面都多一次往返。
三、带 Cookie 的请求要求更严
默认情况下跨源请求不带 Cookie。要带上,请求一侧要声明 credentials: 'include',响应一侧的要求也随之变严:
请求带上 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 请求里发送,这是独立于跨源响应头的另一道限制。
四、能读到响应,不等于能读到所有的头
响应带 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。
五、排查顺序
- 看浏览器控制台的报错原文,它会指出是哪一个头缺失或不匹配。页面脚本里的
TypeError不包含这些信息。 - 看网络面板里有没有 OPTIONS 请求,它的状态码是什么。OPTIONS 失败,问题在预检;没有 OPTIONS 而请求本身有响应,问题在响应头。
- 确认响应头是谁加的。应用、网关、CDN 都可能加,重复出现两个
Access-Control-Allow-Origin同样会失败;出错时的响应(4xx、5xx)经常由另一段代码生成,没有带上这些头,于是真正的错误被一个跨域错误盖住。 - 用命令行复现时带上
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 的三种响应头组合、可读的响应头、预检缓存;页面脚本拿到的结果与接口实际收到的请求逐项对照(验证记录)
参考资料