Nginx 反向代理与 HTTPS 实战:被 X-Forwarded 坑掉的两个晚上
一次 WAF 改写 X-Forwarded-Proto,让 109 条 sitemap 全部失效。从这场真实事故出发,把反向代理与 HTTPS 的转发头逐条拆开讲。
去年给一个站点做优化,遇到一件很怪的事:后台设置里站点地址明明填的是 https://example.com,但生成的 sitemap.xml 里 109 条链接全都是 http://example.com:443/archives/xxx。搜索引擎抓取全部失败,日志里一片 301 循环。
排查了两个晚上才定位到根因:不是后台配置,也不是应用 bug,而是前置 WAF 改写了 X-Forwarded-Proto 和 X-Forwarded-Host,应用照着请求头推导绝对地址,一步错步步错。
这篇把整个链路讲清楚——包括那些只有踩过才知道的细节。
一、一条代理链是怎么长出来的
单机部署时,链路很短:
浏览器 -> nginx:443 -> 应用:8080
但真实业务往往会长成多层。比如典型的加 WAF 场景:
浏览器
-> 云厂商 SLB / CDN (TLS 终结 + 四层转发)
-> BunkerWeb / ModSecurity (WAF,七层检测)
-> nginx (路由、静态资源、限流)
-> 应用容器 (8080 / 8090)
每加一层,就多一次"改写请求"的机会。而 HTTP 协议本身没有"原始协议"这个概念——Host 头只写主机名,不写 https。所以从第二层开始,下游就只知道"有人用 HTTP 连我",完全无法判断用户到底是 HTTP 还是 HTTPS 访问的。
这就是 X-Forwarded-* 系列头存在的理由:让下游通过一个约定俗成的自定义头得知原始请求信息。
二、X-Forwarded 三兄弟
| 头 | 作用 | 典型值 |
|---|---|---|
X-Forwarded-For |
原始客户端 IP,逗号分隔可追加 | 203.0.113.5, 10.0.0.2 |
X-Forwarded-Proto |
原始协议 | https |
X-Forwarded-Host |
原始 Host(含端口) | example.com |
X-Forwarded-Port |
原始端口 | 443 |
nginx 里对应的标准写法:
location / {
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;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_pass http://127.0.0.1:8080;
}
这段配置在只有一层代理时完全正确。问题出在两个地方:
第一,$scheme 只是"我跟上游说话的协议"。 如果 nginx 自己是 HTTP 监听 80、前面还有一层 SLB 做 TLS 终结,那 $scheme 就是 http——明明用户是 HTTPS 来的,下游却被告知是 HTTP。
第二,proxy_set_header 是覆盖不是追加。 上游传来的 X-Forwarded-Proto: https 会被这一行直接冲掉,替换成 http。这个行为是对的(防止客户端伪造),但它同时也抹掉了上游正确的信息。
X-Forwarded-For 是唯一应该追加的:
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
$proxy_add_x_forwarded_for 的值是"已有的 XFF + 逗号 + 客户端 IP"。其余几个头都是覆盖。
三、真实故障复盘
回到开头那个案例。链路是四层:
浏览器 -> 入口 nginx:443(TLS 终结)
-> WAF:8080(容器 litehub-waf)
-> 安全层 nginx:8081
-> 应用:8090
应用是 Halo(Java/WebFlux)。它生成 sitemap、robots.txt、canonical 时,需要一个"站点绝对地址"。Halo 的推导逻辑是:如果后台没显式配置站点地址,就用请求信息拼——
{X-Forwarded-Proto 或 scheme}://{X-Forwarded-Host 或 Host}
逐跳看下去发生了什么:
入口 nginx:listen 443 ssl,$scheme 是 https,$host 是 example.com。传下去正确。
WAF 容器:它自己的 nginx 配置里有这么几行(BunkerWeb 的 reverse-proxy.conf 模板):
proxy_set_header X-Forwarded-Proto $scheme; # WAF 内部是 http 监听,变成 http
proxy_set_header X-Forwarded-Host $http_host; # $http_host 带端口 -> example.com:443
到这里,正确的 https 被覆盖成 http,example.com 被写成了 example.com:443。
安全层 nginx:proxy_set_header X-Forwarded-Proto $scheme——它自己是 HTTP 监听 8081,又一次把值盖成 http。
应用:拼出来 http://example.com:443,写进全部 109 条 sitemap。
为什么在最后一跳钉死能解决
关键洞察:最后一跳是唯一出口。不管前面几层怎么污染,只要在紧挨应用的那一层硬编码:
location / {
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 https;
proxy_set_header X-Forwarded-Host $host; # $host 自动去掉端口
proxy_set_header X-Forwarded-Port 443;
proxy_pass http://127.0.0.1:8090;
}
改完立即生效,109 条坏链接归零。
两个细节值得说:
$hostvs$http_host。$host是"优先取请求行的主机名,其次 Host 头,不带端口";$http_host是"原样取 Host 头,含端口"。这也是example.com:443里那个:443的来源。- 硬编码
https有代价:如果哪天要支持 HTTP 访问,这个值会误报。正确做法是用map判断上游传来的值,或者在入口层就修好。但在"整条链只服务 HTTPS"的场景下,硬编码是最省事、最不容易再被污染的选择。
四、gzip 在反代场景默认不生效
另一个几乎人人都踩的坑。你按官方文档加了:
gzip on;
gzip_types text/plain text/css application/javascript application/json;
gzip_min_length 1024;
然后发现 CSS 还是 112 KB 裸传。原因在于 gzip_proxied 的默认值是 off——当响应是经过代理转发来的,nginx 就跳过压缩。
gzip on;
gzip_vary on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml application/javascript application/json application/xml image/svg+xml;
# 关键:反代场景必须显式打开
gzip_proxied any;
gzip_proxied 的可选值不太好记,实用组合就两个:
any—— 无条件压缩(反代后面跟应用时用这个)no-cache no-store private auth—— 只压缩"不可缓存"的响应,适合有 CDN 的架构
另一个容易出错的地方:gzip_types 不包含 text/html 是刻意的——nginx 默认就会压缩 text/html,再写一遍会报 duplicate MIME type。但如果你的页面上游返回的是 application/xhtml+xml,就得单独加。
还有一条:Content-Length 会和压缩冲突。如果上游带着 Content-Length 返回,nginx 压缩后会改成 Transfer-Encoding: chunked。这是正常行为,但如果中间有一层不认识 chunked 的老代理,就会出问题。
五、WebSocket 与 SSE
WebSocket 必须显式升级:
location /ws/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s; # 默认 60s 会掐断长连接
proxy_send_timeout 3600s;
}
三个必写项:proxy_http_version 1.1(默认 1.0 不支持 Upgrade)、Upgrade 头、Connection: upgrade。少任何一个,握手都返回 400。
SSE(Server-Sent Events)更隐蔽,因为它是普通 HTTP 请求,握手不会失败,但会被缓冲住:
location /sse/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off; # 关键:关掉缓冲
proxy_cache off;
chunked_transfer_encoding on;
proxy_read_timeout 3600s;
}
proxy_buffering off 是重点。开着的话 nginx 会攒够一个 buffer(默认 4K/8K)才往下发,前端看到的就是"消息延迟很久、一下子来一串"。
六、HTTPS 配置要点
server {
listen 443 ssl;
http2 on; # nginx 1.25.1+ 的新语法
server_name example.com;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
几个容易出错的点:
listen 443 ssl http2 与 http2 on。nginx 1.25.1 之前是 listen 443 ssl http2,之后推荐 listen 443 ssl; + 独立的 http2 on;。用旧语法在新版本上会告警。
http2 不能写在纯 HTTP 的 listen 80 上(那是 h2c,需要单独 listen 80 http2,且浏览器普遍不支持)。
Let's Encrypt 续签后要 reload:
nginx -t && nginx -s reload
证书续了但没 reload,nginx 还拿着旧证书在内存里——这是"证书明明续了浏览器还报过期"的标准原因。
验证 HTTP/2 是否真的生效,别靠猜:
echo | openssl s_client -alpn h2 -connect example.com:443 -servername example.com 2>/dev/null | grep ALPN
# 输出 ALPN protocol: h2 才算开了
七、排查方法论
代理链出问题时,最忌讳"直接改配置试试"。有效的方法是从外到内逐跳验证,每层都确认一件事:它收到了什么、它传下去了什么。
第 1 步,看最外层返回的头:
curl -sSI -H 'Accept-Encoding: gzip' https://example.com/ | grep -iE 'http/|content-encoding|server|via'
第 2 步,进每层 nginx,开 debug 日志看那一跳的实际头:
error_log /var/log/nginx/error.log debug;
tail -f /var/log/nginx/error.log | grep -i 'x-forwarded'
第 3 步,直接在应用侧打印收到的头。Spring Boot 加个过滤器:
@Component
public class HeaderDumpFilter implements WebFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
String path = exchange.getRequest().getPath().value();
if (path.startsWith("/debug")) {
exchange.getRequest().getHeaders().forEach((k, v) ->
System.out.println(" " + k + ": " + v));
}
return chain.filter(exchange);
}
}
第 4 步,验证应用生成的绝对地址。别再去看页面,直接打接口:
curl -s https://example.com/sitemap.xml | grep -oE '<loc>[^<]+' | head -3
curl -s https://example.com/robots.txt
这两个文件会把应用对"站点地址"的理解暴露得一干二净。如果 sitemap 里的地址和预期不一致,问题一定在请求头或站点配置,与模板无关。
第 5 步,交叉验证端口。:443 这种多余端口几乎总是 $http_host 或 $host:$server_port 的产物,grep -rn 'http_host' /etc/nginx/ 一把就能找到。
小结
多层代理的复杂度不在于配置语法,而在于信息在每一跳都可能被覆盖。三条经验:
proxy_set_header是覆盖语义,X-Forwarded-For用$proxy_add_x_forwarded_for追加,其余都是覆盖$scheme反映的是"我与上游的协议",不是"用户的协议"。有 TLS 终结层时它会骗你- 最后一跳是唯一能钉死正确值的地方。前面修得再好,只要中间有个组件不听话,还是会被污染
至于那些"明明改了没生效"的情况,八成是忘了 nginx -t && nginx -s reload,或者改的是 /etc/nginx/conf.d/ 下的文件但真正生效的 location 在 default.d/ 里——nginx -T 会打印合并后的完整配置,这个命令值得成为排查第一步。
正在加载评论…