05 · CORS 跨域:原理与本项目真实踩坑
05 · CORS 跨域:原理与本项目真实踩坑
CORS 是前端调用后端接口时最常被卡住的地方。本项目就曾因为 CORS 配置不当,导致登录返回 403 Invalid CORS request——下面用这个真实案例把 CORS 讲透。
5.1 同源策略(Same-Origin Policy)到底是什么
浏览器最核心的安全底线之一,就是同源策略。它规定:一个网页里的脚本,只能读取「与自身同源」的资源的响应内容。
什么叫「同源」
两个 URL 必须 协议(scheme)、域名(host)、端口(port)三者全部相同,才算同源。任何一个不同,就是跨源(cross-origin)。
| 页面地址 | 请求地址 | 是否同源 | 原因 |
|---|---|---|---|
https://uas.aaai.pp.ua | https://uas.aaai.pp.ua/api/users | ✅ 同源 | 三者完全一致 |
http://localhost:5173 | http://localhost:8080/api/users | ❌ 跨域 | 端口不同(5173 vs 8080) |
https://uas.aaai.pp.ua | http://uas.aaai.pp.ua/api/users | ❌ 跨域 | 协议不同(https vs http) |
https://uas.aaai.pp.ua | https://www.aaai.pp.ua/api/users | ❌ 跨域 | 域名(host)不同 |
注意:这里比较的是「网页所在的源」和「它要请求的 URL 的源」,跟后端代码跑在哪个容器里无关。
浏览器到底拦什么、不拦什么
同源策略只拦截「读取响应」,并不拦截「发起请求」。这很反直觉,分清楚两种场景:
- 被拦截:JS 用
fetch/XMLHttpRequest去读另一个源的响应体。比如fetch('https://bank.com/api/balance'),浏览器会挡住你读取返回数据。 - 不被拦截:浏览器允许「只发请求、不读结果」的跨源行为,例如:
<img src="https://other.com/x.png">、<script src="...">、<link href="..."><form action="https://other.com/pay" method="POST">表单提交- 这些标签能跨域「发出去」,但 JS 无法读取它们的响应内容。
正是这种「能发不能读」的设计,催生了 CORS——它是一种让服务器主动授权「某个跨源页面可以读我的响应」的机制。
关键认知:CORS 是浏览器的限制,不是服务器的
CORS 检查发生在浏览器内部。服务器该返回什么还是返回什么,是浏览器拿到响应后,看到没有正确的 CORS 头,才「扣下」响应、不让前端 JS 读到,并抛出错误。
这就是为什么 curl、Postman、fetch 服务端代码测试都「正常」,唯独浏览器 403。命令行工具没有「同源策略」这个守门人。本项目的坑正是这一点的经典体现。
5.2 CORS 协议头详解
CORS 本质是一组 HTTP 头的协商。把下面这 7 个头记熟,你就懂 CORS 了。
请求头:Origin(由浏览器自动添加)
Origin: https://uas.aaai.pp.ua浏览器在所有跨源请求(以及很多同页请求)里自动带上 Origin,标出「发起请求的页面是哪个源」。这是浏览器自动加的,前端 JS 没法伪造、也不需要手动加。
响应头:后端用来「授权」
| 响应头 | 作用 |
|---|---|
Access-Control-Allow-Origin | 允许哪个源读取响应。值就是请求里的 Origin,或 * |
Access-Control-Allow-Methods | 允许的 HTTP 方法,如 GET, POST, PUT, DELETE, OPTIONS |
Access-Control-Allow-Headers | 允许前端带的自定义请求头,如 Content-Type, Authorization |
Access-Control-Allow-Credentials | 是否允许带 Cookie/凭证(值为 true/false) |
Access-Control-Max-Age | 预检结果缓存多少秒,期间不再发预检 |
Vary: Origin | 告诉缓存:响应随 Origin 不同而变化,避免 CDN/缓存串源 |
一个「放行」时后端应返回的响应头示例:
Access-Control-Allow-Origin: https://uas.aaai.pp.ua
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Vary: Origin浏览器怎么判定「要不要放行」
- 浏览器发出请求,自动带上
Origin。 - 后端响应里必须有
Access-Control-Allow-Origin,且值匹配该Origin(或*)。 - 匹配 → 浏览器把响应交给前端 JS;不匹配 / 缺失 → 浏览器拦截,前端看到
CORS error/403 Invalid CORS request。
5.3 简单请求 vs 非简单请求(预检 Preflight)
CORS 把请求分成两类,区别决定了「会不会先发一个 OPTIONS 预检」。
简单请求(Simple Request)
满足全部以下条件才是简单请求:
- 方法是
GET、HEAD、POST之一; - 手动设置的请求头仅限「CORS 安全头」:
Accept、Accept-Language、Content-Language、Content-Type(且值受限,见下)、Range等; Content-Type只能是以下三种之一:application/x-www-form-urlencodedmultipart/form-datatext/plain
简单请求直接发出,浏览器等响应回来,看 Access-Control-Allow-Origin 再决定放行与否。
非简单请求(需要 Preflight)
只要任一条件不满足,就触发预检。常见触发点:
- 方法是
PUT、DELETE、PATCH; Content-Type: application/json(本项目就是它!);- 请求头里带了
Authorization、X-Requested-With等自定义头; - 使用了
fetch的自定义头部或非默认mode。
预检流程(以本项目登录为例):
① OPTIONS /api/auth/login ← 浏览器先发「预检」,不带 body、不带 Authorization
Origin: https://uas.aaai.pp.ua
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
② 后端必须回 200 + CORS 头,表示「这个源、这个方法、这些头我都允许」
③ 浏览器确认预检通过后,才真正发出:
POST /api/auth/login
Content-Type: application/json
Authorization: Bearer <token>
{ "username": "...", "password": "..." }本项目前端
request()函数固定设置了Content-Type: application/json:// frontend/src/App.vue:78-84 const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) } if (token.value) { headers.Authorization = `Bearer ${token.value}` }因为带了
application/json(外加登录后的Authorization头),每一个接口请求都是非简单请求,都会先走 OPTIONS 预检。所以预检能不能过,直接决定整个前端能不能用。
5.4 本项目的真实 Bug:生产域名没进白名单
后端 CORS 配置长这样
// backend/src/main/java/com/yang/backend/config/WebConfig.java:11-17
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:5173", "http://127.0.0.1:5173",
"https://uas.aaai.pp.ua", "http://uas.aaai.pp.ua")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*");
}改之前的样子(Bug 版本)
早期版本 allowedOrigins 里只有本地开发地址,没有生产域名:
// ⚠️ 历史 Bug 版本(已修复,此处仅作教学复现)
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:5173", "http://127.0.0.1:5173")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*");改之后的样子(修复版本,见 WebConfig.java:13-14)
.allowedOrigins("http://localhost:5173", "http://127.0.0.1:5173",
"https://uas.aaai.pp.ua", "http://uas.aaai.pp.ua")为什么「连同源页面都会 403」——核心教学点
这是整个案例最反直觉、也最关键的一步。请看部署链路(详见 5.6):
- 用户访问的是
https://uas.aaai.pp.ua,页面、API 都在这个域名下 → 看起来完全同源。 - 但前端
apiBase为空(App.vue:4,VITE_API_BASE默认''),请求走/api/...,且Content-Type: application/json。 - 因为是非简单请求,浏览器依然会自动带上
Origin: https://uas.aaai.pp.ua(见 5.3)。
记住:浏览器带不带
Origin,跟「是否同源」无关,跟「请求是否非简单」有关。只要是非简单请求,哪怕同源它也会带。
于是后端 Spring 的 CorsFilter(由 WebConfig 注册)收到请求,看到:
Origin: https://uas.aaai.pp.ua它去比对 allowedOrigins 白名单——Bug 版本里没有这个域名 → 校验失败 → 过滤器直接短路返回:
403 Invalid CORS request前端 request() 拿到非 2xx(App.vue:86),抛错 请求失败:403,页面显示登录失败。
为什么 curl 没事?——对照实验
命令行 curl 默认不会发送 Origin 头:
# 没有 Origin 头 → CorsFilter 认为这是同源/无源请求 → 直接放行 → 200
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"adminadmin"}'一旦你手动加上和浏览器一模一样的 Origin:
# 带 Origin,但域名不在白名单 → 复现 403
curl -X POST http://localhost:8080/api/auth/login \
-H "Origin: https://uas.aaai.pp.ua" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"adminadmin"}'返回:
403 Invalid CORS request一句话总结:服务端测着正常、浏览器死活 403,是因为
curl不带Origin绕过了 CORS 过滤器,而浏览器带着生产域名Origin却被挡在白名单外。CORS 是浏览器行为,所以它只惩罚浏览器。
5.5 如何复现与排查
用 curl 二分定位(最快)
# ① 不带 Origin —— 多半 200,说明接口本身没问题
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"adminadmin"}'
# ② 带生产 Origin —— 若变 403,100% 是 CORS 白名单问题
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST http://localhost:8080/api/auth/login \
-H "Origin: https://uas.aaai.pp.ua" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"adminadmin"}'判定规则:① 200 而 ② 403 → 就是 CORS 问题,去查 allowedOrigins。
用浏览器 DevTools 看证据
- 打开
https://uas.aaai.pp.ua,F12 → Network。 - 点登录,找到
POST /api/auth/login:- Request Headers 里应有
Origin: https://uas.aaai.pp.ua。 - 如果失败,往往连 POST 都看不到真正发出——因为浏览器先发了
OPTIONS预检并被 403,真正的 POST 被取消。
- Request Headers 里应有
- 过滤
OPTIONS方法,看那条预检请求:- 响应码
403 Invalid CORS request→ 后端 CORS 配置问题。 - Response Headers 里缺少
Access-Control-Allow-Origin→ 确认未放行。
- 响应码
- 放行时,POST 的 Response Headers 应出现
Access-Control-Allow-Origin: https://uas.aaai.pp.ua。
注意 OPTIONS 预检被「别的过滤器」挡住
预检请求没有 Authorization,也没有 body。如果后端有鉴权过滤器(如本项目 Shiro 的 BearerSessionFilter,见 README.md:290-300)没有放行 OPTIONS,预检就会先撞上 401/403,导致 CORS 失败。本项目在 WebConfig 中把 OPTIONS 列入了 allowedMethods(WebConfig.java:15),且 Shiro 通常放行 OPTIONS,这一点要确认,否则会误诊为「CORS 没配好」。
5.6 部署链路:浏览器其实只跟一个源说话
很多人误以为「前端和后端同域就不需要 CORS」。结合部署链路看就清楚了:
浏览器 (https://uas.aaai.pp.ua)
│ ① 页面 + 所有 /api 请求都发往同一域名 uas.aaai.pp.ua
▼
Nginx Proxy Manager :443 (docker-compose.yml:65-68 暴露 80/443/81)
│ ② 终止 HTTPS,转发到 frontend 容器
▼
frontend:80 (Vue 静态页 + Nginx 反代,见 frontend/nginx.conf)
│ ③ 静态资源直接返回;/api/ 反代到后端
▼
backend:8080 (Spring Boot,含 CorsFilter,见 WebConfig.java)# frontend/nginx.conf:11-17 —— 前端容器把 /api/ 代理到后端
location /api/ {
proxy_pass http://backend:8080;
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;
}关键结论:从浏览器的视角,它只和 uas.aaai.pp.ua 这一个源通信(页面和 /api 同源)。但浏览器发 JSON 请求时仍会带 Origin 头,这个 Origin 头一路穿透 NPM、穿透前端 Nginx 的 proxy_pass,原样到达 Spring 后端。所以即便「看起来同源」,后端 CORS 白名单仍必须包含 https://uas.aaai.pp.ua,否则依然 403。
反过来想:CORS 过滤发生在「离浏览器最近、却仍是后端的那一层」——即 Spring 的
CorsFilter。中间的反向代理只是透传Origin,不会替你做 CORS 决策(除非你专门在 Nginx 层配置,见 5.7)。
5.7 应用层 CORS vs Nginx 层 CORS
CORS 头可以在两层设置,但同一请求不要两层都设,否则可能重复或冲突。
应用层(本项目采用)
由 Spring 的 WebConfig / CorsFilter 在响应里注入 Access-Control-* 头。优点:能精确按 Origin 动态比对白名单、支持 Vary: Origin、逻辑随代码版本走。本项目就是这一层在干活。
Nginx 层
可以在 frontend/nginx.conf 用 add_header 或 more_set_headers 直接写死 CORS 头,甚至对 OPTIONS 直接 return 204。适合「所有源都允许」或「反向代理已经合并同源、本就不需要 CORS」的场景。
# 示例:在前端 Nginx 里统一加 CORS(本项目未采用,仅作对比)
location /api/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin "https://uas.aaai.pp.ua";
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
return 204;
}
proxy_pass http://backend:8080;
}选择建议:同源部署(像本项目 Nginx 反代合并)其实可以完全不做 CORS,因为浏览器认为同源就不会卡。CORS 配置主要服务于「前端和后端确实不同源」的情况(如本地开发
5173调8080)。本项目保留应用层 CORS,是为了兼顾本地开发跨源联调。
关于 Nginx Proxy Manager 的 block-exploits
NPM 有个 block-exploits 选项,但它的规则只匹配 query_string(URL 查询串),不会去检查或拦截 CORS 头相关的请求。因此本项目的 403 与 block-exploits 无关,纯粹是应用层 Spring CORS 过滤器拦截。排查时不必在 WAF/exploit 规则上浪费时间。
5.8 通配符、Credentials 与 allowedOriginPatterns
allowedOrigins("*") 的陷阱
.allowedOrigins("*") // ⚠️ 允许任意源- 方便,但一旦你同时设置了
allowCredentials(true),Spring 会直接报错/拒绝,因为规范规定:带凭证的跨源请求,Access-Control-Allow-Origin不能是*,必须是具体源。 - 生产环境用
*等于对所有人开放你的 API(配合allowCredentials更危险,会泄露用户 Cookie/会话)。
需要凭证时怎么办
当你的请求要带 Cookie 或 HTTP 认证(本项目用 Authorization: Bearer 头,虽不依赖 Cookie,但如果将来要带会话 Cookie):
.allowCredentials(true)
.allowedOrigins("https://uas.aaai.pp.ua") // 必须是具体源,不能用 *allowedOriginPatterns —— 通配又想安全
Spring 5.3+ 提供 allowedOriginPatterns,支持带通配符的具体模式,且能和 allowCredentials(true) 共存:
.allowedOriginPatterns("https://*.aaai.pp.ua", "http://localhost:*")
.allowCredentials(true)它会在运行时把请求的 Origin 与模式做匹配,比 * 安全,又比逐个列域名灵活。本项目当前用精确的 allowedOrigins 列表(WebConfig.java:13-14),已经足够且最清晰。
5.9 常见坑(踩过才记得住)
- 「同源」也要配 CORS:如本项目,页面和 API 同域,但 JSON 请求带
Origin,后端 CORS 过滤器照样校验,漏了生产域名就 403。 - 预检缓存
Access-Control-Max-Age:预检结果会被浏览器缓存一段时间。改了 CORS 配置后浏览器可能还用旧结果,调试时要硬刷新 / 开无痕窗口 / 清空缓存,否则以为改好了其实没生效。 - 多源 + 带凭证:想允许多个具体源且带
allowCredentials,不能用*,要么枚举allowedOrigins,要么用allowedOriginPatterns。 - OPTIONS 被鉴权过滤器拦成 401:预检不带 Token,若
BearerSessionFilter之类的过滤器不放行OPTIONS,预检先 401,CORS 永远失败。确认OPTIONS在allowedMethods且被过滤器放行。 - CORS 与 CSRF 混淆:CORS 解决「浏览器是否让你读响应」;CSRF 解决「恶意网站能否以你的身份发请求」。两者不同。本项目用 Bearer Token 头(CORS 非简单请求天然挡住简单 CSRF 表单),与 Cookie-Session 的 CSRF 模型不同,别混为一谈。
- Nginx 和 Spring 双层都加 CORS 头:会重复甚至冲突,选定一层即可。
- 只配了
GET/POST漏了PUT/DELETE/OPTIONS:本项目的用户编辑(PUT)、删除(DELETE)都会因方法不允许被预检拒绝。
5.10 小结与练习
小结
- 同源 = 协议+域名+端口全同;CORS 是浏览器对「读响应」的限制,不是服务器限制。
- 非简单请求(如
Content-Type: application/json)会先发OPTIONS预检。 - 本项目 Bug:生产域名未进
allowedOrigins,浏览器带Origin: https://uas.aaai.pp.ua被CorsFilter判 403;curl不带Origin所以正常。 - 修复见
WebConfig.java:13-14,上线后生产域名必须显式列入。
练习
- 亲手复现 Bug:把
WebConfig.java:13-14里的https://uas.aaai.pp.ua、http://uas.aaai.pp.ua删掉,重启后端,用浏览器登录,观察 Network 里OPTIONS预检返回403 Invalid CORS request;再用上面 5.5 的curl -H "Origin: ..."复现,最后改回原样。 - 加第三个源:本地再起一个前端(如
http://localhost:4173),把它加入allowedOrigins,验证能联调;然后试着把allowedOrigins改成*并加allowCredentials(true),看 Spring 启动是否报错,理解凭证限制。 - DevTools 取证:正常放行时,在 Network 里分别查看
OPTIONS与POST两条请求的 Request/Response Headers,截图记录Origin与Access-Control-Allow-Origin的对应关系。 - 对比思考:本地开发时
vite.config.js把/api代理到localhost:8080,此时浏览器认为源是localhost:5173,所以 CORS 白名单里的localhost:5173才起作用——思考为什么本地不需要、也不应该删掉它。