一次诡异的跨域排查:看清CORS预检与DevTools的Copy as fetch陷阱
本文由 小茗同学 发表于 2026-06-17 浏览(5)
最后修改 2026-06-17 标签:cors 预检 跨域

本文由AI生成。

Copy as fetch的坑

起因:原页面里一个跨域请求跑得好好的,我用 Chrome DevTools 的 Copy as fetch 把它复制到 Console 执行:

结果立刻报错:

Cross-Origin Resource Sharing error: HeaderDisallowedByPreflightResponse

对天发誓如下2个edit请求所有参数、请求方法、请求地址等都100%一致,如有不一致天打五雷轰:

copy出来的代码如下:

fetch("http://managesupport.xxx.com/article/edit", {
  "headers": {
	"accept": "application/json, text/plain, */*",
	"accept-language": "zh-CN,zh;q=0.9",
	"cache-control": "no-cache",
	"content-type": "application/json;charset=UTF-8",
	"pragma": "no-cache",
	"x-requested-with": "XMLHttpRequest"
  },
  "referrer": "http://xxx.com/",
  "body": "{\"id\":\"947329788\",\"operateType\":\"B\"}",
  "method": "POST",
  "mode": "cors",
  "credentials": "include"
});

把代码原样搬到一个 HTML 文件里,再用代理挂到原域名下访问,依然报错。换 XHR 写法也一样。代码字面意义”完全相同”,结果天差地别。

折腾下来发现,凶手藏在浏览器的 fetch 算法执行顺序里,而 Copy as fetch 的实现细节正是把这个底层细节捅了出来。这篇文章把整条链路梳理清楚。


一、报错本身在说什么

HeaderDisallowedByPreflightResponse 的字面含义只有一个:

实际请求里带了某个 header,但 OPTIONS 预检响应的 Access-Control-Allow-Headers 里没列它。

它不是”控制台更严格”,不是”浏览器随机抽风”,而是 CORS 算法在预检阶段做了一次精确比对,发现申请的 header 集合超出了服务端允许的白名单,于是直接掐断请求 —— 真正的业务请求根本没机会发出去。

所以问题一定出在 header 集合不一致,而不是出在”环境不同”。


二、CORS 预检的核心机制:一次性闸门

要看懂这个 bug,必须先把 CORS 预检的执行模型钉死。它和很多人下意识想象的”每个请求都校验一遍”完全不同。

1. 什么样的请求会触发预检

简单请求(simple request)的判定条件相当严苛,必须同时满足:

  • 方法只能是 GET / HEAD / POST
  • Content-Type 只能是 application/x-www-form-urlencoded / multipart/form-data / text/plain
  • 不能有”自定义 header”(除 CORS 安全列表里那几个外,任何作者主动设置的 header 都会让请求升级)

任何一条不满足,请求就被升级为”非简单请求”,浏览器先发 OPTIONS 预检,预检通过后才发真实请求。

2. 预检看的是”作者声明要发什么”

这是整篇文章最关键的一句话:

CORS 预检算法只统计 author-set headers(作者代码显式设置的 header),不统计浏览器/UA/DevTools 在底层注入的 header。

所谓 author-set headers,就是你在代码里 xhr.setRequestHeader(...)fetch(url, { headers: {...} }) 里写出来的那些。浏览器自己塞的 User-AgentAccept-EncodingCookieReferer,以及 DevTools “Disable cache” 偷偷塞的 Cache-Control / Pragma,都不在统计之列。

预检请求里那一行 Access-Control-Request-Headers,就是这份 author headers 的清单。

3. 预检通过后,正式请求不会再次校验 header

这是另一个常被忽视的点。预检是一次性的闸门:

  1. 浏览器拿着 author headers 去问服务端:”我要用这些 header 发请求,行吗?”
  2. 服务端在 Access-Control-Allow-Headers 里答:”这些可以。”
  3. 浏览器记下这个答复(在缓存有效期内可复用),然后把实际请求发出去。
  4. 实际请求里多了什么 header,浏览器不再二次校验。

这一步很反直觉,但它正是这次 bug 能成立的根本原因。


三、还原现场:两条链路的对比

把原页面和 Console 两条链路画清楚,结论就一目了然了。

链路 A:原页面(成功)

代码里的 author headers 大致是:

xhr.setRequestHeader('Content-Type', 'application/json');
xhr.setRequestHeader('Authorization', 'Bearer xxx');

浏览器执行 fetch 算法:

  1. 检查 author headers → 有 Content-Type: application/json,触发预检
  2. 发出 OPTIONS,Access-Control-Request-Headers: content-type, authorization
  3. 服务端响应 Access-Control-Allow-Headers: content-type, authorization预检通过
  4. 发出真实 POST。此时 DevTools “Disable cache” 在底层把 Cache-Control: no-cachePragma: no-cache 拍到请求上,浏览器自己也塞 User-Agent
  5. 浏览器不再做 header 白名单校验 → 请求成功

注意第 4 步:服务端确实收到了 Cache-ControlPragma,但这跟 CORS 一点关系都没有 —— CORS 检查在第 2 步就已经做完了。

链路 B:Console 里 Copy as fetch(失败)

DevTools 的 Copy as fetch 会把它在 Network 面板看到的所有 header 写进 fetch()headers 字段。”它在 Network 面板看到的”,包含:

  • 你代码里设置的(Content-TypeAuthorization
  • 浏览器底层注入的(部分会被忽略,但不全)
  • DevTools 自己注入的(Cache-Control: no-cachePragma: no-cache ← 凶手
  • 一些 HTTP/2 伪头和 client hints(sec-ch-ua-* 等)

于是这段代码的 author headers 变成了:

fetch(url, {
  method: 'POST',
  headers: {
	'content-type': 'application/json',
	'authorization': 'Bearer xxx',
	'cache-control': 'no-cache',  // 本来是 UA 注入,现在变成作者声明
	'pragma': 'no-cache',		 // 同上
	// ...还可能有 sec-ch-ua、accept-encoding 等
  },
  body: '...',
});

链路变成:

  1. author headers 里多了 Cache-ControlPragma → 触发预检
  2. OPTIONS 的 Access-Control-Request-Headers: content-type, authorization, cache-control, pragma
  3. 服务端 Access-Control-Allow-Headers没有 cache-controlpragma
  4. 预检直接挂HeaderDisallowedByPreflightResponse

服务端的 CORS 白名单写法本来无可厚非 —— 谁会想到客户端会主动声明要发 Cache-Control?这不是 UA 该管的事吗?


四、为什么”代理到原域名再访问”也不行

这是我一开始最不理解的地方:把代码写到 HTML 里、用代理挂到原域名下,请求看上去和原页面一模一样了,怎么还跨域?

答案还是同一句话 —— author headers 不一样

我从 Console 复制过来的 fetch 代码,headers 里依然原样写着 cache-controlpragma。这两个 header 从浏览器/DevTools 注入的”幽灵 header”,已经在我手上变成了代码里写死的字符串。换 XHR 写法、换页面、换部署方式,都改变不了这一点。

只要 author headers 里带着 cache-control,CORS 算法就会把它纳入预检申请,服务端没放行就一定挂。


五、正确的做法

1. 用 Copy as fetch 之后,先砍 headers

headers 砍到只剩业务必需的(Content-TypeAuthorization、自定义业务 token),其余全删。常见的”幽灵 header”包括:

  • cache-control / pragma(DevTools “Disable cache” 注入)
  • accept-encoding / accept-language(UA 注入)
  • cookie(UA 注入,且现代浏览器禁止作者代码设置)
  • sec-ch-ua-* / sec-fetch-*(UA 注入的客户端提示)
  • :authority / :method / :scheme / :path(HTTP/2 伪头,根本不能写)
  • 链路追踪类:eagleeye-traceidx-traceidx-request-id 之类(如果原页面没主动设置而是被网关注入的,照搬也会触发预检)

2. 调试跨域问题时关掉 “Disable cache”

或者至少在排查 CORS 问题时关掉,避免幽灵 header 干扰判断。

3. 看预检响应判断 header 集合差异

在 Network 面板里:

  • 选中失败的请求,看它的 OPTIONS 请求头里 Access-Control-Request-Headers 列了哪些
  • 看 OPTIONS 响应头里 Access-Control-Allow-Headers 又允许了哪些
  • 两边一对比,多出来的就是凶手

4. 服务端的兜底姿势

如果你是后端,面对前端千奇百怪的 header 注入习惯,最稳的写法是把 Access-Control-Allow-Headers 配置成实际接受的所有 header 的并集(包括 cache-controlpragmax-requested-with、链路追踪头等)。或者更激进一些,按照请求头里的 Access-Control-Request-Headers 原样回显(要意识到这种写法会放大潜在的 CORS 配置错误,做好其他校验)。


六、把规则浓缩成一句话

CORS 预检看的是”作者代码声明要发什么 header”,而不是”网络上实际发了什么 header”。预检通过之后,浏览器对实际请求不再做 header 白名单校验。

Copy as fetch 之所以坑,是因为它把”网络上实际发了什么”原样塞进了”作者代码声明”里 —— 让一批本该由 UA/DevTools 在底层透明注入的 header,强行换了个”作者声明”的身份,于是它们从 CORS 算法的雷达盲区跳到了正前方。


七、延伸:还有哪些”环境差异”会引发类似的诡异跨域

把这次的根因抽象出来,类似的”换个环境就挂”现象其实有一类共性 —— author headers 的集合发生了变化

常见的几种触发场景:

  • 不同的 axios/fetch 拦截器:业务封装层会自动加 X-Requested-WithX-CSRF-TokenX-Trace-Id 等,原页面有、测试页没有,反之亦然
  • 代理工具改写 header:Charles / Whistle / Nginx 反代里写了 proxy_set_header,把请求头里某个字段改名,预检就对不上了
  • Service Worker 拦截:原页面注册了 SW,把跨域请求转成同源请求,你以为是跨域成功,其实根本没跨域;测试页没有 SW,裸跨域当然挂
  • HTTP/2 vs HTTP/1.1:少数边缘情况下伪头处理差异会引发 CORS 校验差异
  • credentials 模式include / same-origin / omit 三档对预检的 Access-Control-Allow-Credentials 校验路径不同

下次再遇到”代码完全一样但跨域行为不一样”,第一反应应该是:我代码里的 author headers,和原页面的 author headers,真的一样吗?

把 Network 里两次请求的 OPTIONS 头摆在一起看,答案永远在那里。

补充:豆包,谐音“逗逼”,人如其名

一开始问的是豆包,然后一本正经的胡说八道,刚开始我还真信了,还真以为是控制台预检策略更严格导致,直接误导了排查方向:

后面换了claude code,一下子就发现问题所在: