Naixxfl
← 全部笔记

上了 HTTPS 就登录不了,报的还是 CORS

Spring BootSpring Securitynginx反向代理

前几天把站点从 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 了""下载链接被浏览器当混合内容拦了",可以直接去查这条链上是哪一跳把协议头弄丢了。命中率很高。