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?p=1为例,它的源是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,都需要合法的跨源读取。于是就有了 CORS(Cross-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-Credentials | true表示允许带 Cookie | 与*同时出现 → 浏览器直接拒绝 |
Access-Control-Allow-Methods | 预检回答:允许的方法 | 漏了PATCH |
Access-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 | 🟡 | 取决于接口是否本就该公开。内网接口这样配 = 信息泄露 |
反射Origin+Credentials: true | 🔴 | 真正的高危漏洞 |
SpringallowedOriginPatterns("*")+allowCredentials(true) | 🔴 | 等价于上一行,但看起来无辜得多 |
正则未锚定:~app\.example\.com | 🔴 | app.example.com.evil.com直接通过 |
用startsWith/contains匹配 | 🔴 | 同上,https://app.example.com.evil.com或https://evil-app.example.com |
白名单包含null | 🔴 | sandbox iframe、data:URL 的 Origin 就是null,可被伪造 |
白名单里的http://localhost:3000上了生产 | 🔴 | 攻击者在受害者本机起个服务即可 |
为什么这类漏洞能潜伏半年?看这张表
| 配得太紧(漏配) | 配得太松(反射) | |
|---|---|---|
| 浏览器表现 | ❌ 控制台红字,立刻炸 | ✅一切正常 |
| 上线后 | 用户当天投诉 | 零症状 |
| 谁能发现 | 任何人 | 只有主动安全扫描 |
| 修复动力 | 极强 | 几乎为零(「又没坏」) |
CORS 配得太紧,你会在五分钟内知道;配得太松,你可能永远不会知道——直到有人扫你,或者数据已经出去了。
这也解释了为什么我这次会被扫出问题:这两种错误在因果上是连着的。开发者被跨域报错折磨够了,最后改成反射 Origin,报错消失,问题「解决」了。
所以真正的教训不是「别配错」,而是:
CORS 报错时,不要以「消除报错」为目标,要以「让正确的 origin 通过」为目标。
这两个目标看起来一样,但前者的最优解是反射 Origin,后者的最优解是显式白名单。
五、实用指南 A:什么症状该想到 CORS?
CORS 的报错经常伪装成别的问题,这是最容易浪费时间的地方。
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带SameSite=None; Secure、Allow-Credentials: true、ACAO是具体域名不能是*、前端credentials: 'include' |
| GET 好用,POST/PUT 报错 | 预检问题。九成是鉴权中间件拦了OPTIONS |
OPTIONS返回 401/403 | 同上。预检不带凭证,进了鉴权链必死 |
| 响应 200 但 body 是空的 | fetch用了mode: 'no-cors',拿到的是 opaque 响应。这是「消除报错」的另一种错误姿势 |
| 分页总数读不到 / 下载文件名丢了 | 缺Access-Control-Expose-Headers |
| 本地好,上线炸 | 白名单只写了 localhost;或本地那个vite proxy没跟着上生产(详见第六节) |
| 时好时坏 / 只有部分用户不行 | 缺Vary: Origin,CDN 缓存污染 |
| 改完配置不生效 | 预检缓存(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 配置是不需要 CORS
server { 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 方案 C:nginx 正确写法 + 三个坑
# ① 白名单用 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不是可选项。
坑 2:if块里的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,不是 setAllowedOriginPatternsCorsConfigurationconfig=newCorsConfiguration();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/:80 |
http://app.example.com | 协议写错 |
https://APP.example.com | host 必须小写 |
https://example.com | 漏了子域 |
6.5 三条 curl,上线前必跑
# ① 简单请求:合法 origin 必须通过curl-s-D--o/dev/null https://api.example.com/api/list\-H"Origin: https://app.example.com"# 期望:ACAO 精确等于 https://app.example.com,且有 Vary: Origin# ② 预检:最容易漏的一环curl-s-D--o/dev/null-XOPTIONS https://api.example.com/api/item\-H"Origin: https://app.example.com"\-H"Access-Control-Request-Method: PUT"\-H"Access-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\-H"Origin: 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"---$o"curl-s-D--o/dev/null https://api.example.com/api/user\-H"Origin:$o"|grep-i"access-control-allow"done任何一个被回显,都说明白名单匹配逻辑写错了。
① ② 防「上线报错」,③ 防「上线埋雷」。三条必须都跑。
因为它们防的是方向相反的两种错误——只跑前两条,你会一路"优化"到反射 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 80(2020)起,Cookie 的
SameSite默认值变为Lax。这意味着跨站请求默认不再自动携带 Cookie——大量原本"能用"的跨域方案在这次变更中集体崩溃,必须显式写SameSite=None; 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 看不出来。
always+Vary: Origin是 nginx 里两个最容易漏、后果最诡异的配置。前者导致「401 伪装成 CORS 错误」,后者导致「时好时坏」。- 最好的 CORS 配置,是通过同源反代让它根本不需要出场。
最后一句
如果你今天只带走一件事,请带走这个:
修 CORS 报错时,别以「让红字消失」为目标,要以「让正确的 origin 通过」为目标。
前者的最优解是反射 Origin,五分钟解决问题,半年后变成一份高危报告。
后者的最优解是显式白名单,多花半小时,然后再也不用想它。
Learn more:
- Cross-Origin Resource Sharing
- Cross-Origin Resource Sharing publication history
- W3C Wiki
- Cross-Origin Resource Sharing
- wikipedia.org
- New permission prompt for Local Network Access
- Nuevo mensaje de permiso para el acceso a la red local
- The Private Network Access (PNA) for non-secure contexts deprecation trial is ending—implement the PNA permission prompt
- Local network access restrictions
- docs.stripe.com