fix(cors): 修复生产登录 403「Invalid CORS request」

- 根因:浏览器对**同源**的 POST/PUT/DELETE 也会带 Origin,而 Spring MVC 只看
  "请求里有没有 Origin"(Spring 7.0.9 的 DefaultCorsProcessor 没有同源短路),
  带了又不在白名单里 → 进 Controller 之前直接 403
- WebConfig 的白名单写死为 localhost/内网;注释声称"生产配置见 application-prod.yml
  的 fill.cors.allowed-origins",但该属性根本不存在、也没人读它 ——
  于是"前后端同源部署"的登录/上传/提交必然登不上(生产 2026-10-20 事故)
- 改为配置驱动:FillProperties.Cors + fill.cors.allowed-origins
  (可用 XT_CORS_ALLOWED_ORIGINS 覆盖);prod 默认
  shenxue.jxit.com / admin.shenxue.jxit.com / *.shenxue.jxit.com;
  dev 补上花生壳穿透域名(H5 走穿透时 POST 同样会被拦)
- 启动日志打印生效白名单,排障先看那一行
- 新增 CorsWhitelistTest(3 条):生产 yml 真能绑定、通配符匹配租户子域、陌生源仍被拒
- docs/09 §2.4:本站地址必须在白名单 + 反代必须传
  Host/X-Real-IP/X-Forwarded-For/X-Forwarded-Proto(缺了登录日志 IP 全是 127.0.0.1);
  交接摘要 §一·四十四 记录事故,并写清"1700+ 条 e2e 全绿为什么还是漏了"

为什么 e2e 没抓到:脚本是 Python requests,不带 Origin 头,永远走不到 CORS 分支;
本地浏览器用的 localhost 正好命中写死的 dev 白名单。唯一能暴露它的是"浏览器 + 正式域名"。

验证:生产白名单下 admin / 主域名 / t1 / t12 → 200,evil / 后缀拼接 / http → 403;
真实 RSA 登录 + Origin: https://admin.shenxue.jxit.com → 200、code=0、拿到 token;
mvn test 15/15;verify-all --quick 6/6;check-test-profile 通过。
接口、库表、契约均无变化。
This commit is contained in:
2026-10-20 09:22:16 +08:00
parent 739c0a34a4
commit 50ad577b29
10 changed files with 330 additions and 12 deletions
+75 -1
View File
@@ -3735,7 +3735,7 @@ PC 管理端不受影响(它一直是独立静态托管)。
|---|---|
| H5 与 `/api` **同源** | 微信网页授权的「授权回调页面域名」**只能配一个**,回调落在 `/api/v1/mp/h5/callback`,学生 302 回来的页面必须同域 |
| H5 的接口地址是 `location.origin` | `frontend-mp/src/config/index.ts`:跨源时请求会打到页面所在的源,表现是"页面能开、接口全 404" |
| 同源还省掉 CORS | 否则要放开 `fill.cors.allowed-origins`(`WebConfig` 里那段只在 dev 放行 localhost/内网) |
| 同源"省掉"的是**跨源**,不是 CORS 校验 | ⚠️ **这条原来写的是"同源还省掉 CORS",是错的**:浏览器给同源的 POST 也带 `Origin`,Spring 照样校验白名单 —— 生产登录 403 就是这么来的,见 §一·四十四 |
所以 nginx 的形态是"**页面与接口都从 80 出**",而不是"H5 一个端口、接口另一个端口"。
`application-dev.yml` 的 `h5-base-url`(穿透域名,不带端口)因此**一个字都不用改**。
@@ -3805,6 +3805,80 @@ PC 管理端不受影响(它一直是独立静态托管)。
要调试直接打后端端口。
3. **不做 HTTPS / 证书**:本地由穿透层负责;生产形态由部署侧决定。
---
## §一·四十四 生产登录 403「Invalid CORS request」(2026-10-20,生产事故)
### 一、现象
- `https://admin.shenxue.jxit.com/api/v1/auth/login` 返回 **403**,响应体是一行
`Invalid CORS request`,有 `X-Trace-Id`,但**后端业务日志里一个字都没有**。
- 同一条请求用 curl(**不带 `Origin`**)打同一个地址 → 200,正常返回业务校验 `40001`。
→ **只有浏览器复现**。
- 前端与接口是**同源**的(admin 站点把 `/api` 反代到 `127.0.0.1:8090`)。
按"同源就不用管 CORS"的直觉,这不该发生 —— 而它偏偏发生了。
### 二、根因(两层,都要记住)
1. **浏览器的 CORS 只约束跨源,但它给同源的 POST/PUT/DELETE 也带 `Origin` 头**(GET 不带)。
Spring MVC 的判据是"请求里有没有 Origin",**不是**"是不是真跨源":
`DefaultCorsProcessor#processRequest`(Spring Framework 7.0.9,已反编译确认)
只有 `isCorsRequest → checkOrigin → 不在白名单就 reject`,**没有同源短路**。
于是"同源部署"的登录/上传/提交(全是 POST)照样被 CORS 拦下来。
2. `WebConfig` 的白名单**写死**成 `http://localhost:*` / `http://127.0.0.1:*` / `http://192.168.*.*:*`,
而它的注释写着"生产配置见 `application-prod.yml` 的 `fill.cors.allowed-origins`" ——
**那个属性根本不存在**(`FillProperties` 里没有 `cors`,也没有任何地方读它)。
典型的"声明了但没接上":编译、lint、单测都不会红,`check-wiring.py` 也不覆盖这一类。
### 三、为什么 1712 条 e2e 断言全绿还是漏了
e2e 脚本是 Python `requests`,**不带 `Origin` 头** → 永远走不到 CORS 分支。
本地浏览器验的是 `localhost:5173` / `localhost:80`,正好命中写死的 dev 白名单 → 也绿。
**唯一能暴露它的组合是"浏览器 + 正式域名"**,而那个组合只在生产存在。
> 一般化:**"只在浏览器里发生的行为"(Origin、Referer、预检、Cookie 属性)用脚本测不到**,
> 这类东西必须写进部署检查单,或者用真实浏览器走一遍。
### 四、改了什么
| 位置 | 改动 |
|---|---|
| `FillProperties` | 新增 `Cors.allowedOrigins`(+ `patternsOrDefault()` 兜底) |
| `WebConfig` | 白名单改为**读配置**;启动时 `log.info` 打印生效名单(排障第一眼看它) |
| `application.yml` | 新增 `fill.cors.allowed-origins`:默认 dev 值,可用 `XT_CORS_ALLOWED_ORIGINS` 覆盖 |
| `application-dev.yml` | 补上穿透域名 `http://394nb68658le.vicp.fun` —— **H5 走穿透时 POST 同样会被拦,之前也是坏的** |
| `application-prod.yml` | 默认 `https://shenxue.jxit.com,https://admin.shenxue.jxit.com,https://*.shenxue.jxit.com` |
| `deploy/prod.env.example`、`docs/09` §2.4 / §5 | 同步:写清"本站地址必须在白名单"+反代必须传的四个头 |
### 五、验证(本地起 jar,2026-10-20)
| 场景 | 结果 |
|---|---|
| dev 默认白名单 + `Origin: http://localhost:5173` | 200(进到业务校验) |
| dev 默认白名单 + `Origin: https://admin.shenxue.jxit.com` | **403 `Invalid CORS request`** —— 本地 1:1 复现了生产现象 |
| 以生产白名单启动 + `https://admin.shenxue.jxit.com` / `https://shenxue.jxit.com` / `https://t1.shenxue.jxit.com` / `https://t12.shenxue.jxit.com` | 全部 200 |
| `http://admin...` / `https://evil.example.com` / `https://admin.shenxue.jxit.com.evil.com` | 全部 403(既没放开,也没有后缀绕过) |
| OPTIONS 预检(允许的 Origin) | 200 + `Access-Control-Allow-Origin` 回显;不允许的仍 403 |
### 六、部署时要做的两件事
1. **重新打包 + 重启后端**(改的是 Java 与 yml,不是热加载)。
若临时不想发版:nginx 侧加一句 `proxy_set_header Origin "";` 把 Origin 抹掉即可止血
(同源请求本来也不需要它)—— **只当止血,正式修复仍然要发版**。
2. **nginx 的 `/api` location 补四个头**:`Host` / `X-Real-IP` / `X-Forwarded-For` /
`X-Forwarded-Proto`。不补的表现是**登录日志的 IP 全是 `127.0.0.1`**(审计数据作废)、
`Host` 相关逻辑(租户域名)失效、重定向用错协议。顺带确认
`client_max_body_size 50m` 与 300s 超时(见 `docs/09` §2.4)。
### 七、仍然没做的(有意)
1. **没有做"同源自动放行"**:Spring Framework 7 已移除 servlet 版 `CorsFilter`,
要动态放行只能自己写过滤器;而生产的反代当前没传 `Host`,后端也判断不出
"请求来自哪个公网源"。→ 结论:**白名单必须显式包含部署域名**,
这一条写进了 `docs/09` §2.4、`application.yml` 的注释与启动日志。
2. **没有加 e2e 断言**:Python 侧不带 Origin,加了只能测"白名单内/外",
测不到"生产域名有没有配" —— 那属于部署检查单(已写进 `docs/09` §2.4)。