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
@@ -2,6 +2,7 @@ package cn.tianbaotong.fill.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.List;
import java.util.Map;
/**
@@ -25,6 +26,7 @@ import java.util.Map;
* @param miniProgram 微信小程序(填报二维码)
* @param mpH5 H5 填报端(网页授权)
* @param tenantDomain 租户域名(用子域前缀区分租户,如 t1.xxx.com)
* @param cors 跨域白名单
*/
@ConfigurationProperties(prefix = "fill")
public record FillProperties(
@@ -36,9 +38,46 @@ public record FillProperties(
Storage storage,
MiniProgram miniProgram,
MpH5 mpH5,
TenantDomain tenantDomain
TenantDomain tenantDomain,
Cors cors
) {
/**
* 跨域白名单(Spring 的 {@code allowedOriginPatterns} 语义:可写
* {@code http://localhost:*}、{@code https://*.example.com})。
*
* <h3>⚠️ "本站自己的地址"也必须在这里 —— 这条最反直觉,但它是确定的</h3>
*
* <p>浏览器的 CORS <b>只约束跨源请求</b>,可它<b>给同源的 POST/PUT/DELETE 也带
* {@code Origin} 头</b>(GET 不带)。而 Spring MVC 的判据是"请求里有没有 Origin",
* <b>不是</b>"是不是真跨源":只要带了 Origin 又不在白名单里,直接回
* {@code 403 Invalid CORS request}({@code DefaultCorsProcessor#processRequest},
* Spring Framework 7.0.9 里<b>没有</b>同源短路)。
*
* <p>于是"前后端同源部署 = 不需要 CORS"是错的:登录、上传、提交全是 POST,
* 全都会被这一条拒掉。更麻烦的是它<b>只在浏览器里复现</b> ——
* 所有 E2E 脚本都是 Python,不带 Origin 头,所以测试全绿
* (2026-10 生产事故,见 {@code docs/交接摘要.md} §一·四十四)。
*
* <p>所以配置要按"浏览器地址栏里会出现哪些源"来写,而不是按"谁会跨域调我"来写。
*
* @param allowedOrigins 允许的源(pattern)。生产写真实域名,
* 换域名/加域名时改这里或给 {@code XT_CORS_ALLOWED_ORIGINS}
*/
public record Cors(List<String> allowedOrigins) {
/** 本机开发用的兜底值(base 的 {@code application.yml} 里也有一份,这里防 null)。 */
public static final List<String> DEFAULTS = List.of(
"http://localhost:*",
"http://127.0.0.1:*",
"http://192.168.*.*:*");
/** 没配时退回本机开发值;**生产不许为空**,否则同源 POST 会 403。 */
public List<String> patternsOrDefault() {
return allowedOrigins == null || allowedOrigins.isEmpty() ? DEFAULTS : allowedOrigins;
}
}
/**
* 租户域名:用<b>子域前缀</b>区分租户({@code t1.xxx.com} / {@code t2.xxx.com})。
*
@@ -2,11 +2,14 @@ package cn.tianbaotong.fill.config;
import cn.tianbaotong.fill.security.AuthInterceptor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.List;
/**
* Web 层装配。
*
@@ -14,11 +17,13 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
* {@code /doc.html}、{@code /v3/api-docs/**} 不走业务鉴权,
* 它们由各自的机制保护(actuator 只在内网暴露,文档在生产环境关闭)。
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
private final FillProperties props;
@Override
public void addInterceptors(InterceptorRegistry registry) {
@@ -29,25 +34,38 @@ public class WebConfig implements WebMvcConfigurer {
}
/**
* 跨域配置。
* 跨域配置(白名单来自 {@code fill.cors.allowed-origins})。
*
* <p><b>⚠️ 生产环境必须收紧 {@code allowedOriginPatterns}</b>——写 {@code *} 配合
* {@code allowCredentials} 在浏览器规范下无效,而改成 {@code originPatterns} 放行
* 全部则等于取消同源策略。生产配置见 {@code application-prod.yml} 里的
* {@code fill.cors.allowed-origins}。
* <h3>⚠️ 为什么"同源部署"也离不开这张白名单</h3>
*
* <p>浏览器的 CORS 只约束跨源请求,但<b>同源的 POST/PUT/DELETE 也会带
* {@code Origin} 头</b>;而 Spring MVC 只看"有没有 Origin",不看"是不是真跨源",
* 不在白名单里就回 {@code 403 Invalid CORS request}。登录、上传、提交全是 POST ——
* 于是<b>本站自己的地址必须列在这里</b>,否则"前后端同源"这个部署形态反而一定登不上。
*
* <p>这条曾经写死成 localhost/内网,生产域名 {@code https://admin.shenxue.jxit.com}
* 不在里面,表现就是"接口地址明明通、POST 一律 403",而后端日志里一个字都没有
* (403 是 CORS 处理器在进 Controller 之前回的)。详见 {@code docs/交接摘要.md} §一·四十四。
*
* <p>⚠️ 启动时把生效的白名单打出来(见下面的 info 日志):这类事故里最先要看的就是
* "到底放了谁",而白名单藏在配置里、又只在浏览器里才生效,不打印就只能猜。
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
FillProperties.Cors cors = props.cors();
List<String> patterns = cors == null
? FillProperties.Cors.DEFAULTS
: cors.patternsOrDefault();
registry.addMapping("/api/**")
.allowedOriginPatterns(
"http://localhost:*",
"http://127.0.0.1:*",
"http://192.168.*.*:*"
)
.allowedOriginPatterns(patterns.toArray(String[]::new))
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.exposedHeaders("X-Trace-Id")
.allowCredentials(true)
.maxAge(3600);
log.info("CORS 白名单(/api/**):{} —— 浏览器的同源 POST 也会校验,"
+ "部署域名必须在这里,否则登录会 403「Invalid CORS request」", patterns);
}
}
@@ -50,6 +50,16 @@ fill:
# PKCS#8 私钥(单行 Base64,无 PEM 头尾)
private-key: MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCeHytLx+201AZKhds9B+3LWoVYDJisyimtysGpGip6JLT3iiB2tpK2QL4OAopI/8gaY1iyoxmxU5fUEWa+mPspszciNot4bAdDRZMiCkGOj0zEKJRrTDctnvLVyB6lSd89FX8yD/LS131Big57u9Sk5BYCMO9ZhgCrN1p1qbf/FoJ16zZ3NWSLtGdqVUx2BQjnSRp4Rzf8fq/49MWSXnBmsVm543B85mZMARp24rZ5SwpMwhtrAfVihwqkfaWAfu51oiIwjf0Q8s/wsfth60WoWipG56onwuPtSECKW/Weg5SUQiwoj95iWae+yQFGXRy7hkjk3FhmQArhoK3jiJbrAgMBAAECggEAA3AoD9jECWeGz34Dm1sdx4srVRPJChDHQTkkZtnjzI4ZxTwiopIrtHzJ5SIXAalWH4TDe/bh6yInLfxG4KQQ6gI0k7Dd7Xtj2QmdPmM8s63X/Hfpaj/Y/0vyecWFLRDjIr/h6SPBouX+mjs3P2eBIyi2dcu8mJS6VP66H87V6lz5LyPraFC3ocXTMCabJh+i/Xy4EqV26Mhv+kSrE0CLB2ngfQW98+RgPo5Ndk/A826eGVA16tKsylcTfq3SB/yMSy1xHv/p/IF+PK+Twl3GmJB2jf/dzZpteJIZrPRz+AJ9VJQa35/Chre57JFcA50Avkf5iySzzVfYZDXd0qk6GQKBgQDMD3opopECI5lo21p9+KUZ6poW8iO/Yp2FNYBldMnk+oHcCL6H92dGIZNu0mvcumjEstKH4X9OoI5qV2FFMUrFc6E7NfXWNKOj+eJtLn3WzNRJp6n4x8W8AEeAXzHuv8IbSlG8nsG/g49Tdnaxr2y/iWNj1qGnTfaSyhGRRh133wKBgQDGXla5EmtD9TpCpl7/OSWG8qzv4GfRTaVjC+qz2KnfU5cqiFKLK27uxkP9F8Kdh6EkvOltRA8024FeafpLgTjH3fEKHFywwXSVvssp94IZdoyuxrAqMccReE7akS6IImawm8fu9U0bAwi7WShZHaw2hS9ScM/9cATkDRfRm0DydQKBgHtcQ+rVlzom0UAdk2giH5U6SSrj+Q5jDH0SKeurnb3qXwTo2F4UB3/THnDZjlFi86Etxa5VeQPMdiv9ZRVSRjR1wZus9FAphF/F2cdNWs+3BgL88/kss/ICbTdC1UnS7QJYfG89vj0LyGKZdAnY1jI4WThq8VDWBeEZfWNIctobAoGBAJMoZRmCqoSNpZzQI2UfFdHYsaZ6SUoiWfDyZDjrIIoGTvxEiUeo/vCvsLxPJhcLCOZiJqDifo1t0bki0YW15VOQOEKR68lMmpqEId+ON7uAELmPuBx1NQKmx4ipb2Wab6fQyS9iVsMVKyMg7ye9NYZ0ozh7bDDNKzOq6uKaVEIJAoGAbulymgadkLNVksxPW0O0qIlLVfaOzOeDLGi6ZO4ZBt9wyKgbyMcfwjvuylerkLF/zZqLQ79sOd4JO15ssqHK/YVKiON/A7pDDgxAJpOXxGxy9RXpZWhHL1KBjI9Gcq0gyMSnQbiwpB1Wx3qmjNGDynclW1kNFCQKQpW9xTs4NI0=
# ── 跨域白名单(本地开发)────────────────────────────────────────────
#
# ⚠️ 见 application.yml 里的完整说明:**同源的 POST 也会被 CORS 校验**,
# 所以"H5 由 nginx 托管、页面与接口同源"还不够 ——
# 浏览器给的 Origin 是**页面地址**(穿透域名 / localhost),必须列在这里,
# 否则 H5 的登录、上传、提交会在浏览器里 403(而后端日志里一个字都没有)。
# 这份名单对应三种打开方式:pnpm dev:h5(5174) / xt-nginx(80) / 花生壳穿透。
cors:
allowed-origins: "${XT_CORS_ALLOWED_ORIGINS:http://localhost:*,http://127.0.0.1:*,http://192.168.*.*:*,http://394nb68658le.vicp.fun}"
# ── 材料文件存储(原图 + 压缩改名后的规范件)──────────────────────────
#
# ⚠️ provider=local 只适合本地开发:**多副本部署下必然丢文件** ——
@@ -12,6 +12,8 @@
# WX_MP_CALLBACK_TOKEN
# 其他 XT_LOG_DIR XT_UPLOAD_DIR XT_SEED_ENABLED
# XT_TENANT_DOMAIN_ENABLED XT_TENANT_DOMAIN_BASE
# XT_CORS_ALLOWED_ORIGINS(**换域名/加域名时必看**:浏览器同源 POST 也走 CORS,
# 漏了本站地址 = 登录 403,见 §一·四十四)
#
# ⚠️ 学生端**只发布微信小程序,不部署 H5**:
# 本文件没有 fill.mp-h5 段。后端 /api/v1/mp/h5/** 接口仍在,
@@ -190,6 +192,18 @@ fill:
login-lock-minutes: 15
captcha-after-fails: 2
# ── 跨域白名单(**同源也要配**,见 application.yml 里的完整说明)────────
#
# ⚠️ 浏览器的同源 POST/PUT/DELETE 也会带 Origin,而 Spring MVC 只看"有没有 Origin":
# 不在白名单里 → 直接 403「Invalid CORS request」→ **登录不上**。
# PC 端是 https://admin.shenxue.jxit.com,接口是同一个域名下的 /api,
# 所以这个地址**必须在**名单里(2026-10-20 生产事故的根因)。
#
# 三个来源分别是:主域名(统一入口)/ PC 管理端 / 所有子域(将来的 t1、t2 租户站点)。
# 换域名或加穿透域名时:改这里,或给 XT_CORS_ALLOWED_ORIGINS(逗号分隔)。
cors:
allowed-origins: "${XT_CORS_ALLOWED_ORIGINS:https://shenxue.jxit.com,https://admin.shenxue.jxit.com,https://*.shenxue.jxit.com}"
upload:
# 单文件上限 50MB(PRD §10.1)
max-file-size-mb: 50
@@ -133,3 +133,19 @@ fill:
upload:
# 单文件上限 50MB(PRD §10.1)
max-file-size-mb: 50
# ── 跨域白名单(Spring 的 allowedOriginPatterns 语义)────────────
#
# ⚠️⚠️ **"前后端同源就不用配 CORS"是错的**,这条已经用一次生产事故验证过:
# 浏览器的 CORS 只约束跨源请求,但它**给同源的 POST/PUT/DELETE 也带上 Origin 头**;
# 而 Spring MVC 只看"请求里有没有 Origin"、不看"是不是真跨源" ——
# 带了 Origin 又不在白名单里,就在进 Controller 之前直接 403「Invalid CORS request」。
# 登录/上传/提交全是 POST,所以**本站自己的地址也必须列在这儿**。
#
# ⚠️ 它只在**浏览器**里复现:e2e 脚本是 Python,不带 Origin 头 → 1700+ 条断言全绿也发现不了
# (2026-10-20 生产登录 403 的原因,见 docs/交接摘要.md §一·四十四)。
#
# ⚠️ 换域名、加域名、加穿透域名 → 改这里或给 XT_CORS_ALLOWED_ORIGINS(逗号分隔)。
# 启动日志会打印生效的白名单,排障先看那一行。
cors:
allowed-origins: "${XT_CORS_ALLOWED_ORIGINS:http://localhost:*,http://127.0.0.1:*,http://192.168.*.*:*}"
@@ -0,0 +1,83 @@
package cn.tianbaotong.fill.config;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.core.env.PropertySource;
import org.springframework.core.env.StandardEnvironment;
import org.springframework.core.io.ClassPathResource;
import org.springframework.web.cors.CorsConfiguration;
import java.io.IOException;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* CORS 白名单的最小单测(2026-10-20 生产事故的护栏,见 {@code docs/交接摘要.md} §一·四十四)。
*
* <p>为什么这条要写成单测:它<b>只在浏览器里才会发生</b> ——
* 浏览器的同源 POST 也会带 {@code Origin},而 Spring 只看"有没有 Origin"。
* 所有 e2e 脚本都是 Python(不带 Origin),所以 1712 条断言全绿也发现不了
* "生产域名没进白名单"。能自动化的只有两件事,都放在这里:
*
* <ol>
* <li><b>生产配置里确实有这份名单,且它覆盖管理端域名</b>("声明了但没接上"的另一面:
* 名单写在注释里、代码里却读不到);</li>
* <li><b>通配符真的能匹配子域,而陌生源仍然被拒</b>(既不能漏,也不能变成"全放开")。</li>
* </ol>
*/
class CorsWhitelistTest {
@Test
void 生产白名单必须覆盖管理端域名与租户子域() throws IOException {
CorsConfiguration cfg = new CorsConfiguration();
cfg.setAllowedOriginPatterns(生产白名单());
// PC 管理端的真实 Origin(同源部署下,登录/上传/提交的 POST 都会带它)
assertEquals("https://admin.shenxue.jxit.com",
cfg.checkOrigin("https://admin.shenxue.jxit.com"));
// 主域名(统一入口)
assertEquals("https://shenxue.jxit.com",
cfg.checkOrigin("https://shenxue.jxit.com"));
// 将来的租户站点(t1./t12.)走的是同一条 POST 链路
assertEquals("https://t1.shenxue.jxit.com",
cfg.checkOrigin("https://t1.shenxue.jxit.com"));
}
@Test
void 陌生源仍然被拒_白名单不是全放开() throws IOException {
CorsConfiguration cfg = new CorsConfiguration();
cfg.setAllowedOriginPatterns(生产白名单());
assertNull(cfg.checkOrigin("https://evil.example.com"));
// 后缀拼骗:admin.shenxue.jxit.com.evil.com 不是我们的子域
assertNull(cfg.checkOrigin("https://admin.shenxue.jxit.com.evil.com"));
// 生产是 https,http 版本不放开(避免"降级到 http 的页面也能调接口")
assertNull(cfg.checkOrigin("http://admin.shenxue.jxit.com"));
}
@Test
void 没配时退回本机开发值_不会是空名单() {
assertTrue(new FillProperties.Cors(null).patternsOrDefault().contains("http://localhost:*"));
assertTrue(new FillProperties.Cors(List.of()).patternsOrDefault()
.contains("http://127.0.0.1:*"));
}
/** 把 {@code application-prod.yml} 里的 {@code fill.cors} 真绑一遍(保证"配了就能读到")。 */
private static List<String> 生产白名单() throws IOException {
StandardEnvironment env = new StandardEnvironment();
List<PropertySource<?>> sources = new YamlPropertySourceLoader()
.load("application-prod.yml", new ClassPathResource("application-prod.yml"));
sources.forEach(env.getPropertySources()::addFirst);
FillProperties.Cors cors = Binder.get(env)
.bind("fill.cors", FillProperties.Cors.class)
.orElseThrow(() -> new AssertionError(
"application-prod.yml 里读不到 fill.cors.allowed-origins —— "
+ "同源 POST 会 403「Invalid CORS request」"));
return cors.patternsOrDefault();
}
}