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 条坏链接归零。

两个细节值得说:

  • $host vs $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/ 一把就能找到。

小结

多层代理的复杂度不在于配置语法,而在于信息在每一跳都可能被覆盖。三条经验:

  1. proxy_set_header 是覆盖语义,X-Forwarded-For 用 $proxy_add_x_forwarded_for 追加,其余都是覆盖
  2. $scheme 反映的是"我与上游的协议",不是"用户的协议"。有 TLS 终结层时它会骗你
  3. 最后一跳是唯一能钉死正确值的地方。前面修得再好,只要中间有个组件不听话,还是会被污染

至于那些"明明改了没生效"的情况,八成是忘了 nginx -t && nginx -s reload,或者改的是 /etc/nginx/conf.d/ 下的文件但真正生效的 location 在 default.d/ 里——nginx -T 会打印合并后的完整配置,这个命令值得成为排查第一步。

查看「工程效能」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。

Nginx 反向代理与 HTTPS 实战:被 X-Forwarded 坑掉的两个晚上 - 起航社区