本文由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-Agent、Accept-Encoding、Cookie、Referer,以及 DevTools “Disable cache” 偷偷塞的 Cache-Control / Pragma,都不在统计之列。
预检请求里那一行 Access-Control-Request-Headers,就是这份 author headers 的清单。
3. 预检通过后,正式请求不会再次校验 header
这是另一个常被忽视的点。预检是一次性的闸门:
- 浏览器拿着 author headers 去问服务端:”我要用这些 header 发请求,行吗?”
- 服务端在
Access-Control-Allow-Headers里答:”这些可以。” - 浏览器记下这个答复(在缓存有效期内可复用),然后把实际请求发出去。
- 实际请求里多了什么 header,浏览器不再二次校验。
这一步很反直觉,但它正是这次 bug 能成立的根本原因。
三、还原现场:两条链路的对比
把原页面和 Console 两条链路画清楚,结论就一目了然了。
链路 A:原页面(成功)
代码里的 author headers 大致是:
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.setRequestHeader('Authorization', 'Bearer xxx');
浏览器执行 fetch 算法:
- 检查 author headers → 有
Content-Type: application/json,触发预检 - 发出 OPTIONS,
Access-Control-Request-Headers: content-type, authorization - 服务端响应
Access-Control-Allow-Headers: content-type, authorization→ 预检通过 - 发出真实 POST。此时 DevTools “Disable cache” 在底层把
Cache-Control: no-cache和Pragma: no-cache拍到请求上,浏览器自己也塞User-Agent等 - 浏览器不再做 header 白名单校验 → 请求成功
注意第 4 步:服务端确实收到了 Cache-Control 和 Pragma,但这跟 CORS 一点关系都没有 —— CORS 检查在第 2 步就已经做完了。
链路 B:Console 里 Copy as fetch(失败)
DevTools 的 Copy as fetch 会把它在 Network 面板看到的所有 header 写进 fetch() 的 headers 字段。”它在 Network 面板看到的”,包含:
- 你代码里设置的(
Content-Type、Authorization) - 浏览器底层注入的(部分会被忽略,但不全)
- DevTools 自己注入的(
Cache-Control: no-cache、Pragma: 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: '...',
});
链路变成:
- author headers 里多了
Cache-Control和Pragma→ 触发预检 - OPTIONS 的
Access-Control-Request-Headers: content-type, authorization, cache-control, pragma - 服务端
Access-Control-Allow-Headers里没有cache-control和pragma - 预检直接挂 →
HeaderDisallowedByPreflightResponse
服务端的 CORS 白名单写法本来无可厚非 —— 谁会想到客户端会主动声明要发 Cache-Control?这不是 UA 该管的事吗?
四、为什么”代理到原域名再访问”也不行
这是我一开始最不理解的地方:把代码写到 HTML 里、用代理挂到原域名下,请求看上去和原页面一模一样了,怎么还跨域?
答案还是同一句话 —— author headers 不一样。
我从 Console 复制过来的 fetch 代码,headers 里依然原样写着 cache-control 和 pragma。这两个 header 从浏览器/DevTools 注入的”幽灵 header”,已经在我手上变成了代码里写死的字符串。换 XHR 写法、换页面、换部署方式,都改变不了这一点。
只要 author headers 里带着 cache-control,CORS 算法就会把它纳入预检申请,服务端没放行就一定挂。
五、正确的做法
1. 用 Copy as fetch 之后,先砍 headers
把 headers 砍到只剩业务必需的(Content-Type、Authorization、自定义业务 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-traceid、x-traceid、x-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-control、pragma、x-requested-with、链路追踪头等)。或者更激进一些,按照请求头里的 Access-Control-Request-Headers 原样回显(要意识到这种写法会放大潜在的 CORS 配置错误,做好其他校验)。
六、把规则浓缩成一句话
CORS 预检看的是”作者代码声明要发什么 header”,而不是”网络上实际发了什么 header”。预检通过之后,浏览器对实际请求不再做 header 白名单校验。
Copy as fetch 之所以坑,是因为它把”网络上实际发了什么”原样塞进了”作者代码声明”里 —— 让一批本该由 UA/DevTools 在底层透明注入的 header,强行换了个”作者声明”的身份,于是它们从 CORS 算法的雷达盲区跳到了正前方。
七、延伸:还有哪些”环境差异”会引发类似的诡异跨域
把这次的根因抽象出来,类似的”换个环境就挂”现象其实有一类共性 —— author headers 的集合发生了变化。
常见的几种触发场景:
- 不同的 axios/fetch 拦截器:业务封装层会自动加
X-Requested-With、X-CSRF-Token、X-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,一下子就发现问题所在:

