☰
浏览器跨域禁止访问全链路解析:同源策略、CORS、预检请求与安全配置
2026/10/1 17:26:36 网站建设 项目流程

打开控制台,Network 面板上一条红色的请求横在那里,提示里写着 blocked by CORS policy,前端说是后端没加响应头,后端说我 Postman 测了三遍都是 200,两边谁也不服谁,最后拉个会开到半夜。这种场景在 Web 团队里几乎是周期性上演。所谓"浏览器禁止跨域访问",从来不是网络不通,也不是接口写错了,而是浏览器在拿到响应之后,根据一套叫同源策略的规则,决定要不要把这份响应交给你。请求其实发出去了,服务器也处理了,甚至数据库都写完了,只是浏览器把结果扣在手里没给你。

这篇文章围绕"浏览器禁止跨域访问"这件事,把拦截发生在哪一层、服务端怎么放行、前端和网关侧还能怎么绕、那些看着不像跨域其实是跨域的问题怎么排查,一条链路讲清楚。适合正在被 CORS 报错卡住的前后端开发、负责网关配置的运维、以及需要给团队定规范的技术负责人。文章里的配置都是可以直接抄的,涉及取舍的地方我会说清楚为什么这么选。

1. 先把"被禁止"这件事的因果链捋直

大部分人排查跨域的第一步就错了——去查网络、查防火墙、查服务有没有起来。跨域报错和这些一点关系都没有。它产生的位置不在链路上,而在浏览器内核里,是一个"事后审查"动作。理解这一点,后面所有的解决方案才有落脚点。

1.1 同源策略判定的三个维度,一个都不能少

浏览器判断两个地址是否同源,只看三样东西:协议、域名、端口。三者完全一致才算同源,任意一项不同都算跨域。注意,是端口而不是路径,路径不同不算跨域,/api/user和/api/order在同一个域名端口下属于同源,随便互相请求。

容易被忽略的几种"隐性跨域":

  • http://localhost:3000请求http://127.0.0.1:8000,域名不同(localhost 和 127.0.0.1 是两个不同的 host),端口也不同,双重跨域。
  • https://a.example.com请求https://b.example.com,主域名相同但二级域名不同,依然跨域。
  • 页面用file://直接双击打开的 HTML,它的 Origin 是null,请求任何接口都属于跨域,而且null没法被白名单正常匹配。这也是为什么本地调试一定要起一个 HTTP 服务,别直接双击文件。
  • 端口默认值也算:https://example.com等价于https://example.com:443,写成https://example.com:8443就跨了。

1.2 为什么 Postman 和 curl 永远测不出跨域

这是前后端互相说服不了对方的根源。curl、Postman、服务端之间互相调用、以及移动端原生请求,这些场景根本不执行同源策略,它们只关心 TCP 能不能连上、HTTP 状态码是多少、body 是什么。同源策略是浏览器为了保护用户数据加的一道护栏,它的存在前提是"浏览器里可能同时打开了恶意网站和你的网银"。

所以后端同学说的"我这边是通的"完全成立,前端同学说的"浏览器里就是不行"也完全成立,两边都没撒谎,只是验证环境不同。团队里应该尽早建立一条共识:跨域问题必须在浏览器里复现,看 Network 面板,别用 Postman 当裁判。真要脱离浏览器验证,就用curl -H "Origin: https://your-site.com" -i这种方式,至少把 Origin 头带上,看看服务端回什么,但预检请求的模拟还是得靠浏览器或者专门工具。

1.3 简单请求和预检请求,分界线在哪

浏览器不会对所有跨域请求都先问一遍。它把请求分成两类,处理方式完全不同。

简单请求,浏览器直接发出去,拿到响应后检查Access-Control-Allow-Origin,符合就放行给 JS,不符合就丢弃并报错。简单请求要同时满足:

  • 方法是 GET、HEAD、POST 之一;
  • 请求头只包含安全列表里的字段(Accept、Accept-Language、Content-Language、Content-Type 等少数几个),没有自定义头;
  • Content-Type只允许text/plain、multipart/form-data、application/x-www-form-urlencoded三种。

预检请求,只要有一条不满足,浏览器就先发一个OPTIONS请求,带上Access-Control-Request-Method和Access-Control-Request-Headers,等服务端明确回复"允许你这个方法、这些头",才发真实请求。

这解释了一个特别常见的困惑:某个接口明明加了Access-Control-Allow-Origin,GET 也正常,为什么一改成 POST 提交 JSON 就废了?因为Content-Type: application/json不在简单请求列表里,触发了预检,而服务端没处理OPTIONS,预检直接 404 或 405,真实请求压根没发出去。还有个更隐蔽的:你加了一个Authorization头带 token,同样是自定义头,一样触发预检。

1.4 报错信息与真实原因的对照

控制台的红字经常被误读,我整理了一份常见提示与真实原因的对应关系,排查时先对照这张表能省掉一半时间。

控制台提示关键词真实原因优先检查
No 'Access-Control-Allow-Origin' header响应里完全没有该头服务端是否走了跨域中间件,是否被网关拦截
has been blocked by CORS policy(预检失败)OPTIONS 未正确响应OPTIONS 是否返回 2xx,是否被鉴权拦截器拦下
The value of the 'Access-Control-Allow-Origin' header must not be the wildcard带凭据却用了*改成具体 Origin 并加Vary: Origin
Credentials flag is true, but Allow-Credentials is not 'true'Cookie 跨域未开凭据两端同时开 credentials
Redirect is not allowed for a preflight request预检请求被 301/302去掉重定向,或先到正确域名
blocked by CORS policy: Response to preflight预检返回了非 2xx查反向代理、WAF、限流

注意:跨域失败时,Response 面板里常常看不到任何内容,这不是工具坏了,而是浏览器根本没把 body 交出来。想看真实响应,去看预检那条 OPTIONS 的状态码和响应头,或者用命令行工具单独验证。

2. 服务端放行:CORS 响应头怎么写才既通又安全

确定了是浏览器在做拦截,最正统的解法就是让服务端明确告诉浏览器"这个来源我认"。CORS 不是什么插件或者黑科技,它就是一组 HTTP 响应头,浏览器读它,然后决定放不放行。麻烦的地方在于——头有好几个,组合起来有讲究,写错一个组合就从"通"变成"更不通"。

2.1 Access-Control-Allow-Origin 的三种写法与各自的适用面

这是最核心的一个头,只有三种合法形态。

第一种,固定单域名:Access-Control-Allow-Origin: https://app.example.com。最安全,适合来源确定的内部系统。

第二种,通配符:Access-Control-Allow-Origin: *。表示任意来源都可以读这个响应。它适合完全公开、不依赖 Cookie 和登录态的数据接口,比如公开的天气查询、公开的字典数据。只要接口和用户身份有关,就绝对不能用它。

第三种,动态回显:服务端读请求里的Origin头,在服务端维护的白名单里比对,命中就把该 Origin 原样写回去。这是生产环境最常用的做法。

这里有个必须记住的死规则:只要响应里出现Access-Control-Allow-Credentials: true,Access-Control-Allow-Origin就绝对不能是*。浏览器会直接判定配置非法并拒绝,报错文案就是上面表里那句 wildcard。很多人卡在这里半天,因为单看每个头都"看起来对"。

另外,动态回显时必须同时加上Vary: Origin。原因涉及缓存:如果前面有 CDN 或者反向代理缓存了接口响应,A 站点的响应被缓存后返回给了 B 站点,浏览器看到的 Origin 对不上,照样报错。加上Vary: Origin是告诉缓存层"这份响应对不同的 Origin 要分开缓存"。

用 Node/Express 手写一遍,逻辑更清楚:

const ALLOW_LIST = new Set([ 'https://app.example.com', 'https://admin.example.com' ]); app.use((req, res, next) => { const origin = req.headers.origin; if (origin && ALLOW_LIST.has(origin)) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Access-Control-Allow-Credentials', 'true'); res.setHeader('Vary', 'Origin'); } if (req.method === 'OPTIONS') { res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,PATCH'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization,X-Trace-Id'); res.setHeader('Access-Control-Max-Age', '600'); return res.status(204).end(); } next(); });

这段代码里有个细节值得说:预检请求里同样会带 Origin,如果白名单不匹配,我就不设 Allow-Origin,预检会返回 204 但没头,浏览器照样拒绝。这是符合预期的——非白名单来源本就不该放行。不要把判断逻辑只写在真实请求分支里。

2.2 预检请求 OPTIONS 处理不好,前面全白搭

线上最典型的一类"时好时坏"就是这样:GET 正常,POST JSON 全挂。原因几乎都是 OPTIONS 没被正确应答。

几个高频陷阱:

  • 框架路由没注册 OPTIONS。很多后端框架默认只匹配声明的方法,OPTIONS /api/user直接 404。解决办法是用全局中间件在路由之前拦截 OPTIONS,而不是在业务路由里一个个加。
  • 鉴权拦截器把 OPTIONS 拦了。预检请求不会携带 Cookie 和 Authorization 头(这是规范规定的),如果鉴权中间件对 OPTIONS 也校验 token,必然返回 401,预检失败。正确做法是在拦截器里放行 OPTIONS。
  • Nginx 层把 OPTIONS 转给后端,后端没处理。可以用 Nginx 直接应答:
location /api/ { if ($request_method = OPTIONS) { add_header Access-Control-Allow-Origin $http_origin always; add_header Access-Control-Allow-Methods "GET,POST,PUT,DELETE,OPTIONS" always; add_header Access-Control-Allow-Headers "Content-Type,Authorization" always; add_header Access-Control-Allow-Credentials "true" always; add_header Access-Control-Max-Age 600 always; add_header Vary Origin always; return 204; } proxy_pass http://backend_upstream; }

这里的always参数很容易漏。Nginx 的add_header默认只在 2xx、3xx 状态码下生效,一旦后端返回 4xx/5xx(比如参数校验失败),跨域头就消失了,前端看到的是一个"跨域错误",实际业务逻辑其实正常返回了错误码。加上always后,无论什么状态码都会带上头。这个坑我见过不止一次,排查起来特别费劲,因为错误提示完全指错了方向。

Spring Boot 项目里,如果用@CrossOrigin注解,注意它默认的allowCredentials是 false,而且注解会继承到方法级别,容易出现全局配置和注解打架的情况。更稳妥的是写一个全局配置类实现WebMvcConfigurer,统一注册映射规则。

2.3 带 Cookie 的跨域:两把锁要同时开

跨域请求要在目标站点带上 Cookie,比不带 Cookie 的跨域麻烦一档,需要前端和响应头两边同时配合。

前端侧要显式声明:

fetch('https://api.example.com/user', { method: 'GET', credentials: 'include' });

用 axios 则是withCredentials: true。注意默认值是same-origin,也就是同源才带 Cookie,跨域场景必须手动改。

服务端侧要回Access-Control-Allow-Credentials: true,同时 Allow-Origin 必须是具体来源。

这两把锁少一把都不行。而且还有第三层变量——Cookie 自身的SameSite属性。如果接口域名和站点域名是跨站的(比如站点在a.com,接口在b.com),Cookie 必须设成SameSite=None; Secure,Secure又要求接口走 HTTPS。也就是说,跨站带 Cookie 的场景下 HTTP 是走不通的,这是硬约束。

提示:如果只是主域和子域的关系(页面app.example.com,接口api.example.com),把 Cookie 的 Domain 设为.example.com可以让两边共享,配合 CORS 凭据,通常是最省事的做法,比跨站方案稳定得多。

2.4 常用响应头速查与参数取舍

响应头作用常见取值我的建议
Access-Control-Allow-Origin允许哪些来源读取响应具体域名 /*生产环境永远用白名单回显
Access-Control-Allow-Methods允许的请求方法GET,POST,PUT,DELETE按实际接口收敛,别图省事写全量
Access-Control-Allow-Headers允许携带的自定义头Content-Type,Authorization显式列出,避免盲目回显请求头
Access-Control-Allow-Credentials是否允许携带凭据true只有确实需要才开
Access-Control-Max-Age预检结果缓存秒数600 到 7200别设太大,改配置后不好验证
Access-Control-Expose-Headers允许 JS 读取的响应头X-Total-Count 等分页类接口必须配,否则读不到
Vary缓存键分离Origin有缓存层时必加

Access-Control-Max-Age我一般先设 600 秒,联调期方便改配置立刻生效;上线稳定后可以提到 3600 甚至 7200,减少预检请求数量。别一上来就设 86400,理由是:浏览器缓存了预检结果之后,你改了方法或头的允许列表,客户端在缓存过期前依然按老规则发请求,表现为"配置改了但没生效",很容易误判成缓存或发布问题。

Access-Control-Expose-Headers是新手最容易漏的一个。它不配置的后果是:响应确实回来了,业务也跑通了,但前端读response.headers['x-total-count']拿到 undefined。因为除安全列表里的少数几个头之外,其他自定义响应头默认对 JS 不可见。分页、限流剩余次数这类信息全都会踩到。

3. 前端与网关侧:不动后端也能打通的几条路

现实里很多时候你改不了后端,可能是别的团队维护,可能排期排到下个迭代,也可能就是不想为联调临时加一段将来要删的跨域配置。这时候有几条成熟的路可以走,原理各不相同,适用场景也不同,选错了会比跨域本身更麻烦。

3.1 开发环境代理为什么能绕开跨域

原理一句话:跨域是浏览器和服务器之间的关系,服务器和服务器之间从来没有同源策略。开发服务器(Vite、webpack-dev-server)本质上是一个 Node 进程,你让它作为中间人,浏览器只跟它打交道(同源),它再去请求真正的后端(服务端到服务端,不受限),拿到结果再原样吐回浏览器。整个过程中浏览器看到的始终是同一个 origin,自然不存在跨域。

这条路的优点是不用碰后端、不用管证书,本地联调几乎零成本。缺点是只在开发环境有效,生产环境必须有对应的方案,很多团队上线翻车就是因为把这条当成了最终解。

Vite 的配置长这样:

// vite.config.js export default defineConfig({ server: { port: 3000, proxy: { '/api': { target: 'https://api.example.com', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } });

几个参数的实际含义值得说清楚。changeOrigin: true表示把请求头里的 Host 改成目标服务器的 Host,很多后端和网关会校验 Host,不改会直接 403。rewrite用来剥掉路径前缀,因为前端代码里写的是/api/user,而后端真实路由是/user。如果后端路由本身就带/api,就别写 rewrite,否则会 404。

webpack-dev-server 的写法基本对应:

devServer: { port: 3000, proxy: { '/api': { target: 'https://api.example.com', changeOrigin: true, pathRewrite: { '^/api': '' } } } }

有个细节要注意:代理生效的前提是前端代码里请求的是相对路径。如果你在代码里硬编码了https://api.example.com/user,那浏览器还是直连,代理配置形同虚设。我见过项目里一部分接口写相对路径、一部分写绝对路径,结果一部分通一部分不通,排查时特别迷惑。统一封装 request 模块、baseURL 走环境变量,是避免这类问题的根本办法。

3.2 Nginx 反向代理:把跨域问题变成同源问题

生产环境最推荐的方案,没有之一。做法是把前端静态资源和后端接口放在同一个域名下,用 Nginx 按路径前缀分流:

server { listen 443 ssl; server_name app.example.com; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://backend_upstream/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }

proxy_pass结尾那个斜杠是经典陷阱。写成http://backend_upstream/(带斜杠),Nginx 会把/api/替换掉再转发,/api/user变成/user;写成http://backend_upstream(不带斜杠),则原样转发/api/user。这两者行为完全不同,选哪个取决于后端的实际路由,配错了就是清一色 404,而报错看起来又很像跨域。

做成同源之后的好处不只是跨域消失:Cookie 天然同域共享,不用折腾 SameSite;证书只需要一张;安全策略、CSP 配置都简单。代价是所有流量都过 Nginx,需要关注连接池和超时配置。proxy_read_timeout默认 60 秒,如果有长耗时接口,记得调大,否则会看到 504,而前端同学可能又会误以为"跨域没配好"。

3.3 JSONP、postMessage 这些老办法还有没有用

JSONP 是上古方案,原理是利用<script>标签不受同源策略限制的特性,让服务端返回一段可执行 JS 调用回调函数。它只能发 GET,没法带自定义头,没法读取响应头,错误处理几乎为零。现在还用它,基本只在对接某些十几年没维护过的老接口时。新项目不要考虑。

postMessage解决的是另一个问题——跨域的页面之间通信,不是接口请求。典型场景是父页面内嵌了第三方域名的 iframe,需要双向传数据。它必须校验event.origin,不能无条件信任收到的消息,否则等于给自己开了一个任意脚本注入的口子。

WebSocket 本身不受同源策略约束,可以跨域建立连接。但服务端必须自己校验握手时的Origin头,否则任何人都能连上你的实时通道。这是很多用了 WebSocket 的项目会忽视的安全点:跨域限制没了,但服务端的来源校验得自己补上。

还有document.domain这个老办法,它只在两个页面主域相同、二级域名不同的场景下有效,而且现代浏览器正在逐步取消这个能力,新项目不要用。

3.4 几种方案的对照与选择

方案生效环境能否带 Cookie成本推荐场景
服务端 CORS 头全部可以中,需后端配合对外开放的 API、前后端分离标准方案
devServer 代理仅开发代理层处理低本地联调
Nginx 同源反向代理生产可以低,运维一次性配置自己的前后端部署,首选
JSONP全部不可以低仅对接遗留接口
postMessage全部无关中页面间通信,非接口场景

我的选择顺序是:自己的项目一律走 Nginx 同源代理,从根上消灭跨域;需要给第三方调用时再补 CORS 头并做来源白名单;本地联调用 devServer 代理。JSONP 和document.domain只在维护老系统时保留,新代码里出现就说明走错路了。

4. 那些看起来不像跨域的跨域问题

有一类问题特别消耗时间:控制台报的是别的错,或者压根不报错,但现象和跨域一模一样。下面几个是实战里反复遇到的。

4.1 混合内容:HTTPS 页面请求 HTTP 接口

现象是请求直接不发出去,控制台提示 Mixed Content,浏览器把不安全的子资源请求拦掉了。这不是同源策略,而是 HTTPS 页面不能加载 HTTP 资源这条独立规则。触发场景通常是:主站已经上了 HTTPS,某个老接口还是 HTTP 地址。

处理顺序是:优先把接口升级到 HTTPS;如果短时间内做不到,就通过 Nginx 反代把 HTTP 接口包装成同域的 HTTPS 路径(location /legacy/ { proxy_pass http://old-host/; }),让浏览器看到的是同源 HTTPS。千万不要为了省事在页面里加unsafe-content之类的降级开关,那是把安全基线直接拆了。

顺带说一个特例:localhost 和 127.0.0.1 被浏览器视为安全上下文,所以本地开发时 HTTPS 页面调本地 HTTP 接口通常不会报混合内容,导致本地一切正常、测试环境一上就炸。这种环境差异,越早对齐越好。

4.2 "此连接已被禁止":私有网络访问限制

新版 Chrome 引入了 Private Network Access 机制,大概意思是:如果一个来源是公网页面,它去请求你内网地址(比如192.168.x.x、10.x.x.x)时,浏览器会先做一次额外的预检,要求服务端明确同意。没配的话报错文案大意是"此连接已被禁止,因为它是由某个公共网页发起、试图连接到你专用网络上的设备"。

这种场景主要出现在内部工具、设备管理后台这类页面上。处理方式是在预检响应里补上对应的许可头,或者在架构上把内网服务统一挂在同域网关后面,不直接暴露内网 IP 给公网页面。这类问题在旧版浏览器上完全不会出现,所以升级浏览器之后突然报错,往往就是这个原因。

4.3 登录态莫名其妙丢了

现象是接口返回 200,但返回的是"未登录",或者用户信息为空。这不是跨域拦截,而是 Cookie 没带上。要按顺序检查四件事:

  1. 前端请求有没有开credentials/withCredentials;
  2. 响应头有没有Access-Control-Allow-Credentials: true;
  3. Cookie 的Secure属性是否与当前协议匹配(HTTPS 页面不能用非 Secure 的跨站 Cookie 传递);
  4. Cookie 的SameSite是否与域名关系匹配。

其中第 3、4 条最容易被忽略,因为它们不体现在任何一行前端或后端业务代码里,而是藏在 Set-Cookie 的响应头里。排查时直接去 Application 面板看 Cookie 的字段,比猜快得多。

另外一个常见误会:不同二级域名下的 localStorage 是不共享的。有人把 token 存在 localStorage 里,然后发现跨子域跳转后读不到,就以为是跨域问题。这不是,这是存储隔离,token 得走 Cookie 或者通过 URL 参数传递。

4.4 重定向、缓存和 Service Worker 插的刀

重定向:预检请求遇到 301/302 会直接失败,浏览器明确不允许预检被重定向。常见于接口从 HTTP 跳 HTTPS、或者末尾斜杠补全。解决办法是让前端直接请求最终地址。

缓存:CDN 或者 Nginx 缓存了一份不带 CORS 头的响应,后面所有请求都命中这份缓存,表现为"代码明明改了、服务也重启了,就是不见好"。这时候先看响应头里的X-Cache、Age,确认是不是缓存再动手。

Service Worker:它可能在 fetch 事件里劫持了请求,自己构造了一份响应返回,那份响应自然没有 CORS 头。排查时在 Application 面板里把 Service Worker 注销掉再试一次,能快速排除这个因素。

4.5 一套可以照着走的排查顺序

出了问题别乱试,按这个顺序走,通常十分钟内能定位:

  1. 看 Network 面板有没有 OPTIONS 请求。有,说明触发了预检,重点看它的状态码;没有,说明是简单请求或压根没发出去。
  2. 看 OPTIONS 状态码。404 或 405,说明服务端没处理预检;401,说明鉴权拦了预检;502/504,说明网关或后端有别的毛病。
  3. 看真实请求的响应头里有没有 Allow-Origin。没有,回去查服务端配置有没有生效、有没有被网关覆盖。
  4. 看 Allow-Origin 的值和当前页面 origin 是否严格相等。差一个字符、差一个尾斜杠都不行。
  5. 带凭据的话,同时确认 Allow-Credentials 和 Allow-Origin 的组合是否合法。
  6. 以上都对但还是不行,检查缓存层、Service Worker、重定向。
  7. 本地好、线上坏:对比两边的协议(HTTPS/HTTP)、域名、端口,尤其是混合内容和私有网络这两条规则。

提示:Chrome 和 Edge 的开发者工具在 Network 里都会给出"临时绕过"的选项,用于快速判断是不是跨域问题。它只能证明问题定位,不能作为解决方案,也不要养成长期开着的习惯。

5. 上线前必须守住的安全边界

跨域配置有个特点:写得松一点,问题立刻消失,而且很长一段时间不会有任何异常。等到出事的时候,往往已经是别人拿着你的接口在别家网站上读用户数据了。这一节说的都是踩过才会记住的点。

5.1 通配符加凭据,是绝对红线

前面提过一次,这里再强调一遍并说清后果。如果服务端写成Allow-Origin: *加Allow-Credentials: true,浏览器会直接拒绝,你什么也拿不到。有人为了"让它通",把Allow-Credentials关掉,接口是通了,但登录态也没了。还有人直接反射请求里的 Origin,不校验白名单,那么任何网站都能带着用户的 Cookie 请求你的接口并读到结果,等于把用户数据对所有站点开放。

正确的姿势永远是:维护一份来源白名单,命中才回显,不命中就不回 CORS 头。白名单要放在配置中心或者环境变量里,能改而不用发版。压缩包里的默认值不要图省事写*。

5.2 来源匹配别用宽松的正则

写白名单校验时,origin.endsWith('example.com')这种写法是危险的,因为evil-example.com也能匹配上。origin.includes('example.com')更糟。

稳妥的写法是精确匹配完整 origin:

const ALLOW = new Set(['https://app.example.com', 'https://admin.example.com']); const ok = typeof origin === 'string' && ALLOW.has(origin);

如果确实需要支持一堆子域名,就用严格的正则并锚定两端:

const pattern = /^https:\/\/[a-z0-9-]+\.example\.com$/; const ok = pattern.test(origin || '');

注意正则里\.要转义,^和$都要有,字符集要限制住。这几个符号少一个,白名单就等于没有。

5.3 多层网关覆盖头的排查

规模稍微大点的系统,链路通常是:浏览器 → CDN → SLB → Nginx → 网关 → 应用。任何一层都可能重复设置或清掉 CORS 头。

典型现象是:应用日志里明明打了Allow-Origin: https://app.example.com,但浏览器收到的响应里没有这个头。原因可能是网关只透传固定字段,把自定义响应头过滤掉了;也可能是有两层都在设这个头,出现了重复,浏览器按第一条处理,而第一条是空的或者错的。

排查办法是从最外层往后一层层抓包比对。Nginx 里可以临时加add_header X-Debug-Origin "$http_origin" always;观察 Origin 有没有一路传下来。确认是网关过滤后,就去网关的响应头白名单里把跨域相关字段加进去。这个动作最好在联调阶段就做掉,别等到上线。

5.4 配置变更的灰度与回滚

跨域配置属于"改一次影响全站"的东西,我建议按这个节奏走:

  • 先用Max-Age设小值(比如 60 秒),方便调整后快速生效;
  • 白名单新增来源时先观察一段时间,确认没有非预期来源在调;
  • 记录每次配置变更的时间和内容,出问题时能快速对应上;
  • 准备回滚路径——配置中心改回去就能生效,别把白名单硬编码在要重新发版的代码里。

另外一个很容易被忽略的点:跨域配置改错了,浏览器是会缓存的。你在服务端把配置改回来了,用户端可能还在用缓存的预检结果。所以变更窗口尽量避开业务高峰,并且在验证时用无痕窗口,避免被本地缓存干扰。

6. 我这些年处理跨域问题的几条私人经验

第一条经验是关于定位方法的。永远先看预检请求的状态码,而不是先看业务请求的响应内容。九成的跨域问题答案都在那条 OPTIONS 请求上,业务请求可能压根就没发出去过。很多人把精力花在检查业务接口的返回体上,方向从一开始就是偏的。

第二条是关于团队协作的。跨域问题天然横跨前后端,最浪费时间的不是解决,而是互相证明"不是我这边的错"。我的做法是在项目里准备一份固定的诊断脚本,前端跑一下就能输出:当前页面 origin、目标接口 origin、是否触发预检、预检状态码、响应头里有没有 Allow-Origin。拿这份输出去找后端,沟通成本能降一大半。

第三条是关于方案选择的。能用同源代理解决的,就不要用 CORS。同源代理是从根本上消除问题,配置一次长期有效,也没有安全边界要维护;CORS 是每个接口都要正确配置的持续性成本,还会随人员流动出现回退。只有在接口要对外部调用方开放、你做不了域名统一的时候,CORS 才是必要选择。

第四条是关于"临时方案"的。开发环境代理、本地 hosts 映射这类手段,本身没问题,问题是没人负责把它们在生产环境替换掉。我建议在项目规范里明确一条:任何为了让接口跑通而引入的代理配置,都要在对应的任务单里写明"生产环境由什么方案承接",否则上线前一定会有人忘了这件事,然后又是一轮深夜排查。

最后分享一个小技巧。如果怀疑是缓存导致跨域配置没生效,在请求 URL 后面临时加一个随机参数(比如?_t=时间戳)再试一次,注意这只对 URL 缓存有效,对预检结果缓存无效,预检缓存得靠无痕窗口或者改 Max-Age 来排除。这两个缓存走的机制不同,别混着判断。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询