
CORS 配置有问题啥是跨域啥是CORS一篇从「安全扫描报告说我 CORS 配置有问题」出发把跨域这件事彻底讲明白的文章一、开局两个场景你大概至少中过一个场景 A你写好了前端本地跑得飞起。一上测试环境控制台红了一片Access to fetch at https://api.example.com/user from origin https://app.example.com has been blocked by CORS policy: No Access-Control-Allow-Origin header is present on the requested resource.你用 Postman 试了一下——完全正常200数据也对。后端同学看了日志也是 200。于是双方开始互相怀疑「是不是你前端写错了」场景 B我这次遇到的系统跑了半年没有任何人报错一切风平浪静。然后安全团队做渗透测试报告里写着【高危】CORS 配置不当存在跨域资源共享漏洞你一脸茫然这功能明明好着呢怎么就成漏洞了这两个场景其实是同一件事的两个极端。场景 A 是配得太紧场景 B 是配得太松。而绝大多数 CORS 漏洞的成因就是开发者被场景 A 折磨了两天然后随手把配置改松了报错消失皆大欢喜。这篇文章想讲清楚三件事CORS 到底是个什么机制它保护谁、防谁、由谁执行遇到什么症状应该想到「这是 CORS」遇到什么症状不该想到配置到底该放在哪、怎么写、怎么自测。二、地基三个概念先说人话2.1 什么是「源」Origin源 协议 域名 端口三者完全相同才叫同源。以https://app.example.com/list?p1为例它的源是https://app.example.com。注意对比对象是否同源原因https://app.example.com/other/path✅ 同源路径不参与判断http://app.example.com❌协议不同https://api.example.com❌子域不同https://example.com❌主域也算不同https://app.example.com:8443❌端口不同记住一句Origin 里没有路径、没有斜杠、默认端口会被省略。这句话后面会救你一次。2.2 什么是同源策略Same-Origin Policy这是浏览器最古老、最核心的安全机制。一句话A 网站的 JS 代码默认不能读取 B 网站的数据。打个比方。浏览器是一栋写字楼每个源是一个独立办公室。同源策略就是门禁卡你的卡只能开自己办公室的门隔壁公司的文件柜你碰不到。为什么必须这样想象一下如果没有它你在网银登录着同时打开了evil.com。evil.com的 JS 可以直接fetch(https://your-bank.com/api/balance)。因为浏览器会自动带上网银的 Cookie请求在服务端看来完全合法然后返回值被evil.com读走了。同源策略正是为了阻止这个。它拦住的不是「请求」而是**「跨源读取响应内容」**。2.3 那 CORS 是什么同源策略太严了。现实中前后端分离、微服务、开放 API都需要合法的跨源读取。于是就有了 CORSCross-Origin Resource Sharing跨源资源共享。CORS 是同源策略的「例外申报机制」。它本质上是资源持有方服务端在响应里贴一张便条「我允许https://app.example.com的页面读取我的这份响应。」浏览器看到这张便条核对一下确实写着当前页面的源就放行没看到便条或者上面写的不是你就把响应内容扣下来在控制台报错。2.4 ⚠️ 全文最重要的一句话服务端只负责「声明」真正的「执行」发生在浏览器里。这句话你需要现在就刻进去因为它能一次性解释后面所有的困惑为什么Postman / curl 能通浏览器不通因为 curl 不看那张便条它自愿不遵守。CORS 对它毫无约束力。为什么后端日志显示 200前端却说跨域因为请求确实发出去了、后端确实处理了、响应确实回来了然后在最后一步被浏览器扣下。两边说的都是真话别再争论请求到没到。为什么CORS 配置错了会变成漏洞因为浏览器只是个执行者它不判断你的白名单是否合理。你说允许它就允许。三、工作原理浏览器和服务器到底在聊什么CORS 请求分两类搞混这两类是排查跨域时最大的时间浪费。3.1 简单请求一趟就完事满足全部以下条件的叫简单请求方法是GET/HEAD/POST除浏览器自动加的头之外只用了这几个头Accept、Accept-Language、Content-Language、Content-Type有限制、Range有限制如果有Content-Type值只能是text/plain、multipart/form-data、application/x-www-form-urlencoded三者之一流程很直白浏览器 ──────► 服务器 GET /api/list Origin: https://app.example.com 浏览器 ◄────── 服务器 200 OK Access-Control-Allow-Origin: https://app.example.com { data: [...] } ↓ 浏览器核对便条 → 通过 → 交给 JS注意这里请求是真的发出去了。哪怕最后被拦服务端的数据库写入也已经发生了。CORS 从来不阻止请求只阻止 JS 读取响应。3.2 预检请求先派人打个招呼一旦不满足简单请求条件——最常见的就是Content-Type: application/json或者用了PUT/DELETE或者加了Authorization头——浏览器就会先发一个OPTIONS请求去问路① 预检 浏览器 ──────► 服务器 OPTIONS /api/item Origin: https://app.example.com Access-Control-Request-Method: PUT Access-Control-Request-Headers: content-type,authorization 浏览器 ◄────── 服务器 204 No Content Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Methods: GET,POST,PUT,DELETE Access-Control-Allow-Headers: Content-Type,Authorization Access-Control-Max-Age: 600 ↓ 预检通过才发真实请求 ② 真实请求 浏览器 ──────► 服务器 PUT /api/item ...预检有两个极容易踩坑的特性坑一预检请求不携带 Cookie也不携带Authorization头。它是一个「匿名问路」。所以如果你的鉴权中间件挡在最前面OPTIONS必然被判 401/403预检失败真实请求根本不会发出。症状GET 接口一切正常POST/PUT 全部报跨域。解法把OPTIONS放行到鉴权链之前。坑二预检结果会被缓存缓存期内不再预检。Access-Control-Max-Age控制缓存秒数但浏览器有上限Chrome 最多 2 小时Firefox 最多 24 小时。症状你改完配置刷新页面还是报错以为没生效。换个无痕窗口试试。3.3 完整头字段速查请求头浏览器自动加JS 改不了头说明Origin当前页面的源。浏览器强制添加无法伪造Access-Control-Request-Method仅预检。真实请求打算用什么方法Access-Control-Request-Headers仅预检。真实请求打算带哪些自定义头响应头服务端要配的就是这些头说明常见错误Access-Control-Allow-Origin允许的源。只能是单个值或*不能是列表想写多个域名用逗号分隔 → 无效Access-Control-Allow-Credentialstrue表示允许带 Cookie与*同时出现 → 浏览器直接拒绝Access-Control-Allow-Methods预检回答允许的方法漏了PATCHAccess-Control-Allow-Headers预检回答允许的自定义头写*但覆盖不了Authorization必须显式列出Access-Control-Expose-Headers允许 JS 读取的响应头见下方说明Access-Control-Max-Age预检缓存秒数—Vary: Origin告诉缓存层「响应随 Origin 变化」见下方说明关于Expose-Headers一个高频困惑跨源时JS 默认只能读 7 个响应头Cache-Control、Content-Language、Content-Length、Content-Type、Expires、Last-Modified、Pragma。所以如果你的分页总数放在X-Total-Count里或者下载文件名放在Content-Disposition里——接口 200数据正常但这两个头在 JS 里是null。这时候人往往想不到是 CORS会去怀疑后端没返回。用 curl 一看明明有更加困惑。关于Vary: Origin一个隐蔽的雷你的响应内容取决于请求的Origin但 CDN / nginx 缓存默认不看Origin。结果是 CDN 缓存了给 A 站的响应B 站命中同一份缓存拿到了别人的Allow-Origin。症状时好时坏、只有部分用户不行、换个网络就好了。这是 CORS 里最难查的一类问题加一行Vary: Origin就能避免。四、澄清CORS 到底在保护谁这一节是理解「为什么会被扫出漏洞」的关键。很多人对 CORS 的定位有根本性误解。误解一「CORS 能保护我的接口不被别人调用」错。CORS 完全拦不住任何攻击者。攻击者写个脚本、用 curl、用 Pythonrequests一行 CORS 头都不看你的接口该怎么调还是怎么调。CORS 不是访问控制机制。接口的访问控制靠的是鉴权Token / Session / 签名跟 CORS 是两条完全独立的线。误解二「CORS 是浏览器给我添麻烦」反了。CORS 是放宽限制的机制不是增加限制的机制。真正限制你的是同源策略CORS 是同源策略给你开的那扇门。没有 CORS跨源读取是彻底不可能的。误解三「配松一点没关系反正没人攻击」这是最危险的一条需要展开讲。CORS 真正保护的是用户的身份凭证不被第三方站点盗用。攻击模型长这样用户已登录 your-app.com浏览器里有 Cookie ↓ 用户被诱导访问 evil.com钓鱼链接、论坛帖子、广告 ↓ evil.com 的 JS 执行 fetch(https://api.your-app.com/user/profile, { credentials: include }) ↓ 浏览器自动带上 your-app.com 的 Cookie请求完全合法 ↓ 服务端返回用户的完整个人信息 ↓ 浏览器检查 Access-Control-Allow-Origin ├── 没有 / 不匹配 → 扣下evil.com 读不到 ✅ └── 回显了 evil.com → 放行数据被读走 注意这里的关键攻击工具是受害者自己的浏览器。攻击者不需要拿到 Cookie只需要借用受害者的浏览器去发请求、再把响应读走。所以下面这段代码是把大门直接拆了// ⚠️ 这不是配置这是漏洞response.setHeader(Access-Control-Allow-Origin,request.getHeader(Origin));response.setHeader(Access-Control-Allow-Credentials,true);「谁来我就回显谁 允许带凭证」任何网站的 JS 都能带着用户的 Cookie 读你的接口并读到完整响应体。浏览器为什么不拦因为你亲口说了允许。危险写法清单写法危险等级说明ACAO: *Credentials: true规范禁止浏览器直接拒绝。反而是安全的但功能坏了ACAO: *无 Credentials取决于接口是否本就该公开。内网接口这样配 信息泄露反射OriginCredentials: true真正的高危漏洞SpringallowedOriginPatterns(*)allowCredentials(true)等价于上一行但看起来无辜得多正则未锚定~app\.example\.comapp.example.com.evil.com直接通过用startsWith/contains匹配同上https://app.example.com.evil.com或https://evil-app.example.com白名单包含nullsandbox iframe、data:URL 的 Origin 就是null可被伪造白名单里的http://localhost:3000上了生产攻击者在受害者本机起个服务即可为什么这类漏洞能潜伏半年看这张表配得太紧漏配配得太松反射浏览器表现❌ 控制台红字立刻炸✅一切正常上线后用户当天投诉零症状谁能发现任何人只有主动安全扫描修复动力极强几乎为零「又没坏」CORS 配得太紧你会在五分钟内知道配得太松你可能永远不会知道——直到有人扫你或者数据已经出去了。这也解释了为什么我这次会被扫出问题这两种错误在因果上是连着的。开发者被跨域报错折磨够了最后改成反射 Origin报错消失问题「解决」了。所以真正的教训不是「别配错」而是CORS 报错时不要以「消除报错」为目标要以「让正确的 origin 通过」为目标。这两个目标看起来一样但前者的最优解是反射 Origin后者的最优解是显式白名单。五、实用指南 A什么症状该想到 CORSCORS 的报错经常伪装成别的问题这是最容易浪费时间的地方。5.1 一眼就是 CORS 的症状说明控制台出现blocked by CORS policy一定要读完后半句它会明确告诉你缺哪个头Network 面板里冒出一个你没写过的OPTIONS预检。它失败了真实请求根本没发fetch抛TypeError: Failed to fetch信息极少规范要求不向 JS 泄露细节。必须去看 Console而不是 catch 到的 error 对象5.2 伪装成别的问题的重点症状真实原因Postman 通浏览器不通执行者差异。好消息说明后端逻辑全对只缺响应头后端日志全 200前端说跨域正常现象响应被浏览器最后一步扣下。别争论请求到没到直接查响应头登录接口 200下一个请求就 401跨站 Cookie 没带上。四件套必须齐后端Set-Cookie带SameSiteNone; Secure、Allow-Credentials: true、ACAO是具体域名不能是*、前端credentials: includeGET 好用POST/PUT 报错预检问题。九成是鉴权中间件拦了OPTIONSOPTIONS返回 401/403同上。预检不带凭证进了鉴权链必死响应 200 但 body 是空的fetch用了mode: no-cors拿到的是 opaque 响应。这是「消除报错」的另一种错误姿势分页总数读不到 / 下载文件名丢了缺Access-Control-Expose-Headers本地好上线炸白名单只写了 localhost或本地那个vite proxy没跟着上生产详见第六节时好时坏 / 只有部分用户不行缺Vary: OriginCDN 缓存污染改完配置不生效预检缓存Max-Age或多实例配置不一致或 CDN 缓存了旧响应重定向之后失败跨源重定向会重新走一遍 CORS 检查且预检请求不允许跟随重定向5.3 看起来像 CORS其实不是症状真实原因HTTPS 页面调 HTTP 接口失败Mixed Content在 CORS 之前就被拦了。而且localhost有「潜在可信」豁免所以本地不报、上线才报ERR_CONNECTION_REFUSED/ 502 / 504后端挂了、反代配错。跟 CORS 无关但因为错误响应里没有 CORS 头浏览器会顺带报一个 CORS 错误极具误导性只有某些用户 / 某些浏览器不行广告拦截插件、企业代理、隐私模式屏蔽第三方 Cookie一条判据Network 面板里如果连状态码都没有显示(failed)或0先怀疑网络层如果有正常状态码但 JS 读不到才轮到 CORS。六、实用指南 B配置到底放在哪回到最开始那个问题「所以主要就是改 nginx 配置吧」答案是取决于架构而且有一条铁律比「改哪里」更重要CORS 响应头必须只有一个地方负责输出。不是「nginx 加一份、应用再加一份保险」。两份配置 两个Access-Control-Allow-Origin 浏览器判定非法 整个 CORS 直接失败。诡异的是这时候你 curl 一看两个值都是对的于是开始怀疑人生。所以第一步不是「怎么写」而是「先决定谁负责」。6.1 四种方案方案头写在哪适用场景评价A. 同源反代哪都不写前后端能收敛到同一域名⭐最优解B. 应用层Spring / Express / Django 中间件后端单一服务直接对外灵活能按接口区分C. nginx / 网关nginxadd_header多服务、多语言要统一策略统一但语法有坑D. CDN / API Gateway边缘配置已有 CDN 层注意Vary和缓存6.2 方案 A最好的 CORS 配置是不需要 CORSserver { server_name app.example.com; location / { root /var/www/frontend; # 前端静态资源 try_files $uri /index.html; } location /api/ { proxy_pass http://backend:8080; # 后端 proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }在浏览器眼里https://app.example.com/api/xxx和页面是同源的。于是CORS 从未介入、预检不存在、Cookie 天然携带、SameSite不用改、一行ACAO都不用写——跨域漏洞在架构层面就不可能存在。顺便解决一个高频困惑如果你本地开发用的是vite proxy或webpack devServer.proxy那你本地跑的就是方案 A。上线如果不继续用方案 A等于换了一套架构——这就是「本地好、上线炸」最常见的原因。6.3 方案 Cnginx 正确写法 三个坑# ① 白名单用 map必须放在 http 块不能放 server / location map $http_origin $cors_origin { default ; # 不匹配 → 空值 ~^https://app\.example\.com$ $http_origin; # 注意 ^ 和 $ 锚定 ~^https://admin\.example\.com$ $http_origin; } server { location /api/ { # ② 先剥掉后端可能已经输出的 CORS 头保证只有一个地方负责 proxy_hide_header Access-Control-Allow-Origin; proxy_hide_header Access-Control-Allow-Credentials; # ③ 预检短路不打扰后端天然绕开鉴权链 if ($request_method OPTIONS) { add_header Access-Control-Allow-Origin $cors_origin always; add_header Access-Control-Allow-Credentials true always; add_header Vary Origin always; add_header Access-Control-Allow-Methods GET,POST,PUT,PATCH,DELETE,OPTIONS always; add_header Access-Control-Allow-Headers Content-Type,Authorization always; add_header Access-Control-Max-Age 600 always; add_header Content-Length 0; return 204; } add_header Access-Control-Allow-Origin $cors_origin always; add_header Access-Control-Allow-Credentials true always; add_header Vary Origin always; add_header Access-Control-Expose-Headers X-Total-Count,Content-Disposition always; proxy_pass http://backend:8080; } }坑 1不加always出错时头就消失了。nginx 的add_header默认只对 2xx / 3xx 生效。不加always后端返回 401 或 500 时 CORS 头不输出浏览器于是报一个 CORS 错误把真正的 401 完全掩盖掉。你会花两小时查跨域实际问题是 token 过期。always不是可选项。坑 2if块里的add_header不继承外层。nginx 的继承规则是当前层只要写了任何一条add_header就完全不继承上层的。所以 OPTIONS 分支里必须把ACAO、Credentials、Vary全部重写一遍。少写一个预检就挂而主请求看起来完全正常。坑 3白名单必须用map别用if拼字符串。map的妙处不匹配时值为空字符串而nginx 对空值的add_header会直接跳过不输出。这天然实现了「白名单外不给头」不需要额外判断。正则一定要^...$锚定。写成~app\.example\.com会被https://app.example.com.evil.com匹配上——这就是扫描器最常报的那类漏洞。6.4 方案 B应用层正确写法// Spring注意是 setAllowedOrigins不是 setAllowedOriginPatternsCorsConfigurationconfignewCorsConfiguration();config.setAllowedOrigins(List.of(https://app.example.com,https://admin.example.com));config.setAllowedMethods(List.of(GET,POST,PUT,PATCH,DELETE));config.setAllowedHeaders(List.of(Content-Type,Authorization));config.setAllowCredentials(true);config.setMaxAge(600L);白名单字符串本身也是坑源以下写法都会静默失配不报错就是不匹配超难查错误写法问题https://app.example.com/末尾多了斜杠。Origin 永远无斜杠、无路径https://app.example.com:443不该写默认端口Origin 会省略:443/:80http://app.example.com协议写错https://APP.example.comhost 必须小写https://example.com漏了子域6.5 三条 curl上线前必跑# ① 简单请求合法 origin 必须通过curl-s-D--o/dev/null https://api.example.com/api/list\-HOrigin: https://app.example.com# 期望ACAO 精确等于 https://app.example.com且有 Vary: Origin# ② 预检最容易漏的一环curl-s-D--o/dev/null-XOPTIONS https://api.example.com/api/item\-HOrigin: https://app.example.com\-HAccess-Control-Request-Method: PUT\-HAccess-Control-Request-Headers: content-type,authorization# 期望2xx/204 Allow-Methods 含 PUT Allow-Headers 含两者# 若返回 401/403 → 鉴权链拦了预检# ③ 反向验证恶意 origin 必须不被回显curl-s-D--o/dev/null https://api.example.com/api/list\-HOrigin: https://evil-random-9f8a7b.com# 期望响应里没有任何 Access-Control-Allow-* 头再跑一组绕过变体这些是扫描器真正在找的东西foroin\https://app.example.com.evil.com\https://evil-app.example.com\https://app.example.com:1337\http://app.example.com\null;doecho---$ocurl-s-D--o/dev/null https://api.example.com/api/user\-HOrigin:$o|grep-iaccess-control-allowdone任何一个被回显都说明白名单匹配逻辑写错了。① ② 防「上线报错」③ 防「上线埋雷」。三条必须都跑。因为它们防的是方向相反的两种错误——只跑前两条你会一路优化到反射 Origin。七、渊源这套机制是怎么长成今天这样的理解历史包袱很多设计得怪怪的地方就说得通了。1995 — 同源策略诞生。Netscape Navigator 2.0 引入 JavaScript同时就引入了同源策略。它是整个 Web 安全模型的基石比 CORS 早了十年。2005 前后 — JSONP 时代。跨域读数据没有正规途径于是有了 JSONP利用script标签不受同源策略约束的特性让服务端返回一段callback({...})的 JS 代码来偷渡数据。它能用但代价很大只支持 GET无法处理错误而且本质上是让第三方在你的页面里执行任意代码。JSONP 的种种不堪直接催生了对正规方案的需求。2005 — W3C 第一版草案。最初叫Authorizing Read Access to XML Content还带着浓重的 XML 时代气息。2009 — 浏览器各自为政。IE8 搞了个自己的XDomainRequest功能残缺不支持自定义头、不支持凭证Firefox / Safari 走的是标准 CORS 路线。这段分裂期是很多老代码里奇怪 polyfill 的来源。2014 年 1 月 16 日 — CORS 成为 W3C 正式推荐标准。这是 CORS 的成年礼各大浏览器实现开始统一。2020 年 6 月 2 日 — W3C CORS 规范退役。内容并入 WHATWG 的Fetch Living Standard。所以今天你要查权威定义别翻 W3C 那份去看 Fetch 标准——这也是为什么 MDN 上的 CORS 文档链接都指向 Fetch。2020 至今 — 收紧期。浏览器发现「服务端主动声明」这个模型太依赖开发者不犯错于是开始从客户端加码Chrome 802020起Cookie 的SameSite默认值变为Lax。这意味着跨站请求默认不再自动携带 Cookie——大量原本能用的跨域方案在这次变更中集体崩溃必须显式写SameSiteNone; Secure。COOP / COEP / CORP一系列新头出现用于隔离进程、防御 Spectre 类侧信道攻击。Private Network Access → Local Network Access。最初的设计是「对内网请求强制 CORS 预检、由内网设备自己 opt-in」但推行不下去内网设备很难上 HTTPS。于是 Chrome 改变策略从 Chrome 142 起公网站点访问用户本地网络需要弹窗获得用户许可不再依赖设备自己声明。这条演进线的方向很明确从「完全信任服务端的声明」逐步走向「浏览器和用户拥有更多否决权」。因为二十年的实践证明了一件事——服务端的 CORS 配置出错的概率实在太高了。八、总结关键点回顾一句话总结CORS 是服务端向浏览器出示的一张「许可便条」声明「哪些源的 JS 可以读我的响应」。它由服务端声明、浏览器执行因此拦不住任何攻击者只能保护用户的凭证不被第三方站点盗用。十条 Takeaway同源 协议 域名 端口三者全同。路径不算默认端口省略Origin 结尾没有斜杠。CORS 不阻止请求只阻止 JS 读取响应。请求早就到服务端了副作用也已经发生了。服务端只是声明浏览器才是执行者。所以 curl / Postman 完全无视 CORS——它们能通不代表配置正确。Postman 能通、浏览器不通 后端逻辑全对只缺响应头。这其实是个好消息。CORS 不是访问控制。接口安全靠鉴权两条独立的线别指望 CORS 挡住攻击者。反射 Origin Allow-Credentials: true 高危漏洞。等于亲手关掉同源策略且功能上毫无异常这是它能潜伏半年的原因。GET正常但POST/PUT报错先查预检。OPTIONS不带 Cookie 和 Authorization必须在鉴权之前放行。CORS 头只能有一个地方负责输出。重复输出 直接失败且 curl 看不出来。alwaysVary: Origin是 nginx 里两个最容易漏、后果最诡异的配置。前者导致「401 伪装成 CORS 错误」后者导致「时好时坏」。最好的 CORS 配置是通过同源反代让它根本不需要出场。最后一句如果你今天只带走一件事请带走这个修 CORS 报错时别以「让红字消失」为目标要以「让正确的 origin 通过」为目标。前者的最优解是反射 Origin五分钟解决问题半年后变成一份高危报告。后者的最优解是显式白名单多花半小时然后再也不用想它。Learn more:Cross-Origin Resource SharingCross-Origin Resource Sharing publication historyW3C WikiCross-Origin Resource Sharingwikipedia.orgNew permission prompt for Local Network AccessNuevo mensaje de permiso para el acceso a la red localThe Private Network Access (PNA) for non-secure contexts deprecation trial is ending—implement the PNA permission promptLocal network access restrictionsdocs.stripe.com