上了 HTTPS 就登录不了,报的还是 CORS
前几天把站点从 http 切到 https。nginx 配好、证书装上、跳转也对,登录页打开一切正常。
输账号密码,回车。
没反应。不报错,不转圈,什么都没发生。
后端日志一条异常都没有,这是最难受的地方 —— 连从哪查起都不知道。
日志为什么是干净的
后来才明白这条线索本身就是答案的一半。
Spring Security 的 CORS 检查发生在 CorsFilter 里,位置在过滤器链靠前的地方,请求根本到不了 Controller。被拒时它走的是 DefaultCorsProcessor.rejectRequest(),直接把响应写成 403 就返回了,没有抛异常,自然也不会有人去记 ERROR。
想让它开口说话,把这个包的日志级别调下来就行:
logging:
level:
org.springframework.web.cors: DEBUG
它会打出 Reject: 'https://tools.example.com' origin is not allowed 这样一行。我当时是先用 curl 找到方向,事后才发现这个开关,顺序反了,记在这里省得下次再绕。
用 curl 把变量控住
浏览器那边能看到的信息太少,我直接拿 curl 打登录接口,故意用一个错密码,就为了看它回什么。三次请求只差一个 Origin 头:
# 带 https 的 Origin —— 浏览器实际发的就是这个
curl -i -X POST -H "Content-Type: application/json" \
-H "Origin: https://tools.example.com" \
-d '{"username":"x","password":"wrong"}' \
https://tools.example.com/api/v1/auth/login
→ HTTP/1.1 403
Invalid CORS request
# 不带 Origin
→ HTTP/1.1 401
{"success":false,"message":"用户名或密码错误"}
# 带 http 的 Origin
→ HTTP/1.1 401
三行一摆出来结论就有了。接口是好的,密码错会老实回 401,唯独带 https 的 Origin 那次被拦在外面。
每次只动一个变量,让它自己指认凶手。这个笨办法比读源码快得多。
同一个域名,凭什么算跨域
Spring 判断同源,比的是 Origin 头和当前请求对象的协议、主机、端口。三项全等才算同源,判定代码在 CorsUtils.isCorsRequest() 里,取当前请求那一侧用的是 ServletServerHttpRequest.getURI(),也就是 request.getScheme() 加 getServerName() 加 getServerPort() 拼出来的。
问题就在这三个方法上。
nginx 把 TLS 终止掉了,转给后端时走的是普通 http:
location /api/ {
proxy_pass http://web:18080;
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;
}
头是传了的,X-Forwarded-Proto: https 明明白白写在那儿。但 request.getScheme() 读的是连接本身,不看任何头。所以 Tomcat 眼里:
- 我这个请求是
http://tools.example.com,端口按 80 算 - 浏览器却自称来自
https://tools.example.com,端口 443
协议对不上,判定为跨域,转头去查 CORS 白名单。白名单里只有本地开发那几个地址。403。
走 http 的时候两边都是 http,这个 bug 根本不存在。它专门等你上 HTTPS 那天才现身。
改一行
server:
port: ${SERVER_PORT:18080}
forward-headers-strategy: native
Spring Boot 默认值是 none,也就是那些转发头一概不理。打开之后 Tomcat 的 RemoteIpValve 会在请求进入过滤器链之前,拿这些头把请求对象本身改写掉:getScheme() 返回 https,getServerPort() 返回 443,getRemoteAddr() 换成 X-Forwarded-For 里的真实客户端地址。同源判断随之就对了。
两个可选值的差别值得记一下。native 由 Servlet 容器完成,时机最早,连 Tomcat 自己生成的 Location 响应头都会跟着改对;framework 走 Spring 的 ForwardedHeaderFilter,在过滤器层包一个 HttpServletRequestWrapper。跑在 Tomcat 上就用 native,没什么理由选另一个。
验证也简单,直接对后端容器端口发一个伪造头的请求,看它认不认:
docker exec toolbox_nginx curl -s -o /dev/null -w '%{http_code}\n' \
-H 'Origin: https://tools.example.com' \
-H 'X-Forwarded-Proto: https' \
-X POST -H 'Content-Type: application/json' \
-d '{"username":"x","password":"wrong"}' \
http://web:18080/api/v1/auth/login
回 401 就是认了,还回 403 说明开关没生效。
差点就把域名加进白名单
我第一反应其实是把生产域名塞进 CORS 白名单,两分钟就能上线。
停下来想了一下才发觉这是在把一个同源请求当跨域放行,绕过去而不是修好。
绕过去只修好了登录这一条路。别的依赖协议的地方还是错的,而且会分批出现:重定向的 Location 头、OAuth2 的 redirect_uri、接口文档里的 server 地址、分页响应里的 next 链接、Cookie 的 Secure 属性判定。这些会在往后几周里一个一个冒出来,到那时我早就不记得它们和今晚这件事是同一个根因了。
顺带还有个副作用,白名单里躺着生产域名之后,真正的跨域调用也被放行了。
症状在 CORS,病根在协议识别。修症状那条路通常都更顺手。
打开它的前提
RemoteIpValve 不是无条件相信转发头的,它有一个 internalProxies 正则,默认只信这些来源:
10.x.x.x
172.16.x.x ~ 172.31.x.x
192.168.x.x
169.254.x.x
127.x.x.x
::1
这条默认值救过不少人。Docker 的默认网段落在 172.17 上,正好在里面,所以容器间通信开箱即用。反过来说,要是自定义网络用了这几段之外的地址,开关打开也不起作用,而且同样是静默的。
真正要警惕的是另一种部署形态:服务直接暴露在公网上还打开这个开关,等于允许任何人自称来自任意 IP、任意协议。 伪造一个 X-Forwarded-For 就能绕过基于 IP 的限流和审计。
我这边后端容器不对宿主机映射端口,只有同一个 docker 网络里的 nginx 够得着,够不着就伪造不了。开之前先确认一句:到我这里的流量,是不是必然经过我自己的代理。
顺手捡到的东西
改完之后 getRemoteAddr() 拿到的是真实访客 IP。在这之前它一直是 nginx 容器的内网地址。
限流、审计、访问日志这几样东西,到这一刻才开始有意义。之前记了半天,记的全是 nginx 的地址。
链路一长会更麻烦
一层 nginx 就够我翻一次车。跳数多了以后,每多一跳就多一次失真机会:
浏览器 → CDN / 边缘网关(TLS 在这终止)→ Spring Cloud Gateway → 业务服务
X-Forwarded-For 是累加的,每跳追加自己看到的对端地址,所以业务服务拿到的是一串 IP,取哪一个要看 trustedProxies 怎么配。X-Forwarded-Proto 不累加,只反映最外面那一跳,中间任何一层用 proxy_set_header X-Forwarded-Proto $scheme 覆盖掉,后面全错 —— 这一行在内层代理上恰恰是最常见的复制粘贴。内层该写的是 $http_x_forwarded_proto,或者干脆别设。
还有个容易漏的头是 X-Forwarded-Prefix。应用挂在 /app 这类路径前缀下时,不传这个头,Spring 生成的绝对 URL 里就会少掉前缀。
规矩归纳起来就三句:边缘统一设置,中间层透传不重写,业务服务只信内网来源。
以后要是听见谁说"测试环境的回调地址怎么变成 http 了""下载链接被浏览器当混合内容拦了",可以直接去查这条链上是哪一跳把协议头弄丢了。命中率很高。