ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

反向代理导致HTTPS降级?Nginx配置排查与修复指南

反向代理导致HTTPS降级?Nginx配置排查与修复指南 大概每个搞过 Web 后端的人都撞见过这么一幕明明已经把证书挂到了 Nginx 上浏览器地址栏也锁得严严实实用户却在某个页面提交完表单后啪地一下跳到了http://开头的地址接着整个请求链路又变成明文。更气人的是这种降级不是偶发的有时候换个网络环境就出现有时候清了缓存又恢复。排查一圈下来问题往往不在证书、不在业务代码而藏在你那个一直默默工作的反向代理配置里。这篇文章就围绕反向代理场景下的 HTTPS 降级问题拆一拆最容易被忽略的几个坑并附上我实际用过的排查方法和修复配置。适合正在用 Nginx 或同类组件做反代又经常被“为什么又变 http 了”折磨的运维、后端和前端同学。1. 先搞明白反向代理为什么天然会造成“协议降级”1.1 边缘加密模型TLS 只保护外网这一段先说一个最基础的事实绝大多数反向代理在转发 HTTPS 请求时并不会把 TLS 隧道整个打穿到后端。浏览器到 Nginx 这一段是加密的 HTTPSNginx 用私钥解密后内部再通过普通 HTTP 去访问后端的应用服务。你去看 Nginx 配置大部分是这种写法server { listen 443 ssl; server_name example.com; ssl_certificate /etc/nginx/ssl/example.com.pem; ssl_certificate_key /etc/nginx/ssl/example.com.key; location / { proxy_pass http://127.0.0.1:8080; } }proxy_pass指向的是http而不是https这非常正常因为 TLS 已经在边缘终止了。这种做法在工程上极其常见后端不用关心证书证书统一放在入口层管理新加一台应用服务器也不需要拷贝私钥性能还能通过 Nginx 的静态文件和处理能力得到提升。但代价就是后端应用默认情况下完全不知道客户端实际是走 HTTPS 进来的。这就带来一个认知上的误区很多人以为“HTTPS 降级为 HTTP”一定是指握手被攻击或证书失效。实际上在你自己的架构里“降级”有两种完全不同的含义。第一种是连接层面的真实降级也就是用户最终访问的 URL 是http://浏览器和服务器之间确实没有加密。第二种是应用层返回的链接降级用户请求的是https://example.com/login后端在 302 响应的Location头里写了一个http://开头的地址浏览器就跟着这个地址走了于是地址栏从 https 变成了 http。第二种本质上是“引导降级”——加密本身没有问题是反向代理把协议信息弄丢了。绝大多数线上的诡异降级都属于第二种。1.2 需要区分开的两种“降级”连接降级和地址降级既然要排查第一件事就是判断你碰到的是哪一种。你可以先做一个最简单的复现手动打开 https 地址看 URL 是否保持再在浏览器里提交一个表单或点击一个会触发重定向的按钮观察跳转后的地址最后看页面源码里有哪些http://的资源链接。如果 URL 一开始是正常的跳转后变成 http优先怀疑重定向和Location头。如果页面一开始加载就是 http那要检查 80 端口的跳转规则、DNS 记录甚至是不是有人在外部做了 URL 重写。我遇到过不少同事看到浏览器地址栏变成 http第一反应就是吊销证书、换套件、关代理测试结果越搞越乱。其实只要理解了反向代理是“边缘终止 TLS”这个模型就很容易想明白代理和后端之间的明文 HTTP 是不可消除的你能控制的是“让后端知道真实协议”以及“让代理返回给客户端的地址保持 https”。接下来的所有配置本质上都在解决这两件事。2. 四个隐形开关最常导致 HTTPS 请求降级2.1 重定向返回的 Location 头absolute_redirect 与端口陷阱Nginx 里有两个开关会影响自动生成的跳转地址absolute_redirect和port_in_redirect。absolute_redirect默认是 on也就是说当你写return 301 /login时Nginx 不会返回一个相对地址而是帮你拼出一个完整的Location: https://example.com/login。拼的时候用的 scheme 来自当前连接host 来自请求头端口则受port_in_redirect控制。默认情况下这个开关是 off对于 80 和 443 这样的标准端口没问题可一旦你的反代服务跑在 8443 这类非标准端口生成的 Location 会丢掉端口客户端拿到一个https://example.com/...连过去直接失败。这个坑隐蔽性很高因为页面能打开但每次跳转就断一次。如果你写的是return 301 https://$host$request_uri;那端口写不写、写多少都取决于你这段字符串本身跟上面的开关无关。所以我个人推荐在 80 端口做跳转时直接采用这种显式写法避免依赖默认开关。类似的还有server_name_in_redirect默认是 on意思是如果请求的 Host 头里的域名和server_name不一致Nginx 可能用server_name来拼 Location导致用户明明访问的是一个临时域名跳转后却到了主域名。处理方式同样是尽量用$host变量不要依赖 Nginx 自动拼接。2.2 后端生成绝对 URL 时X-Forwarded-Proto 没传等于“瞎了”另一个高频降级场景来自后端应用自己生成的链接。比如 Java 的 Spring Boot 用RedirectView做跳转Python 的 Flask 用redirect(url_for(...))Go 的 net/http 也会在设置 Location 时拼r.URL.Scheme。问题在于后端在反向代理后面看到的请求行几乎永远是 HTTP/1.1因为 Nginx 转发给它的就是明文 HTTP。如果后端没有拿到“客户端其实是走 HTTPS 来的”这个信号它生成的绝对 URL 自然全是http://。这个信号就是X-Forwarded-Proto请求头。Nginx 里对应的配置是proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host;注意光加这个头还不够。后端框架必须“信任”这个头才会用它覆盖自己看到的 scheme。以 Flask 为例不加ProxyFix的话werkzeug 只认本机 socket 的 scheme加了之后才会把X-Forwarded-Proto当成真实协议。Spring Boot 则需要显式配置server.forward-headers-strategy: framework不是天然就认的。很多同学配了 Nginx 的头之后发现还是没用十有八九是后端没启动代理信任机制。2.3 80 与 443 两个 server 块互相打架很多人配置 80 跳 443 时喜欢在两个 server 块里同时塞业务逻辑。比如 80 端口也配了proxy_pass结果用户从 https 页面点击某个链接时Nginx 根据默认 server 把请求接到了 80 端口的 location导致一个本应保持 https 的请求变成了 http。正确做法是 80 端口只保留一句话return 301 https://$host$request_uri;所有真实业务逻辑全部放到 443 的 server 块里。这样无论用户怎么进来最终都只会落到 HTTPS 这一条路上。还要避免一种隐蔽的循环443 server 块里的某个 location 又配置了return 301 http://...或者后端应用里写死了redirect(http://...)。这种“显式降级”比配置缺漏更容易被忽视因为它不是偶发是每次必现。一旦看到“明明配了 https 还是一直跳 http”先全局搜索一下代码里有没有写死的 http 跳转比在 Nginx 里折腾半天更快。2.4 HSTS 与浏览器缓存的“延迟翻车”HSTS 这个头值得单独说。它本质上是告诉浏览器在某个时间范围内这个域名只能用 HTTPS 访问不需要先从 http 跳一次。这能治“首跳降级”的毛病但前提是你的 301 处理必须保持正确。因为第一次访问http://example.com的时候浏览器还没收到过 HSTS它不知道该走 https只能先发一个 http 请求出去等反向代理把它 301 到 https然后响应里的 HSTS 才会被记录。如果这个 301 配错了比如跳到了一个 http 地址浏览器就会把这次错误响应也缓存下来后面把 https 请求错误地重定向到 http。HSTS 的max-age设得越长踩坑后的回滚时间就越长。我见过有人一上来就设max-age31536000; includeSubDomains结果没几天发现某个子域名还有 http 资源在跑整个子域都打不开了。本地开发阶段建议先设一个短值比如add_header Strict-Transport-Security max-age300 always;等线上确认全站 HTTPS 资源完整后再慢慢拉长。注意always参数很关键否则 Nginx 默认可能只在部分响应码下输出这个头。3. 完整实操从复现降级到彻底修复3.1 先复现一个最小化降级现场说这么多不如动手一次。我搭一个最小环境一个 Flask 应用跑在127.0.0.1:8080前端 Nginx 监听 80/443证书用自签的就行。先故意不配X-Forwarded-Proto看看会发生什么。Flask 代码from flask import Flask, redirect, url_for app Flask(__name__) app.get(/login) def login(): # 模拟登录成功后跳转到控制台 return redirect(url_for(dashboard)) app.get(/dashboard) def dashboard(): return meta charsetutf-8h1Dashboard/h1 if __name__ __main__: app.run(host0.0.0.0, port8080)Nginx 配置先写成“有问题”的版本server { listen 80; server_name example.com; return 301 https://$host$request_uri; } server { listen 443 ssl; server_name example.com; ssl_certificate /etc/nginx/ssl/example.com.pem; ssl_certificate_key /etc/nginx/ssl/example.com.key; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; } }这个配置里80 跳 443 是没问题的。但你用 https 打开/login后Flask 的redirect(url_for(dashboard))会生成什么Flask 根本不知道外部协议是 https它会基于内部 socket 的明文 HTTP 来拼 URL生成一个http://example.com/dashboard的 Location。浏览器看到 https 页面返回了 http 地址二话不说就跳过去。于是地址栏轻轻松松从 https 掉到 http。3.2 修复方案协议透传、重定向归一、HSTS修复的第一步是把协议信息传进内网。修改 Nginx 的 location 块location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-For $remote_addr; }第二步是让 Flask 信任这个头from werkzeug.middleware.proxy_fix import ProxyFix app.wsgi_app ProxyFix(app.wsgi_app, x_proto1, x_host1)x_proto1表示信任第一层X-Forwarded-Proto头。如果你的链路前面还有 CDN可能需要把这个数字调大对应代理层数。改完之后Flask 内部读取到的url_for就会生成 https 开头Location 自然正确。这里多说一句如果后端代码写死了绝对地址比如redirect(http://example.com/dashboard)那不管 Nginx 怎么传头都没用属于业务代码层面的问题。所以我建议业务代码里统一用相对路径或者基于request.url拼接。第三步是给上游返回的 Location 做一个兜底改写。有些老后端你改不动或者第三方系统不受控它返回的Location: http://backend:8080/...那可以在 Nginx 层强制改写proxy_redirect http:// http://;这句的意思是如果上游响应的 Location 头以http://开头就替换成https://。注意这是通用写法遇到更具体的场景可以写成proxy_redirect http://127.0.0.1:8080/ /;把内部地址和端口直接抹掉换成根路径这样客户端拿到的是干净的相对路径后续跳转全凭浏览器自己补全协议。最后加上 HSTSadd_header Strict-Transport-Security max-age31536000; includeSubDomains always;完整修复后的配置大致是server { listen 80; server_name example.com; return 301 https://$host$request_uri; } server { listen 443 ssl; server_name example.com; ssl_certificate /etc/nginx/ssl/example.com.pem; ssl_certificate_key /etc/nginx/ssl/example.com.key; add_header Strict-Transport-Security max-age31536000; includeSubDomains always; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-For $remote_addr; proxy_redirect http:// http://; } }到这里这个最小案例就从“必现降级”变成了“稳定 HTTPS”。3.3 验证方案curl、日志和抓包观察验证不要只看浏览器。我用这套流程先curl -I http://example.com/login预期返回 301Location 是https://example.com/login再curl -I -k https://example.com/login预期返回 200且任何后续请求都不再出现 http 的 Location。curl -I不带-L可以看到每一跳的响应头而不是被浏览器静默带上非常利于定位问题。如果还需要确认代理层内部对协议的理解可以在 Nginx 的 access_log 格式里加上$scheme变量log_format proxy_debug $remote_addr [$time_local] $request $status $http_referer scheme$scheme xfp$http_x_forwarded_proto; access_log /var/log/nginx/proxy_debug.log proxy_debug;这样你能看到 Nginx 对每个请求理解到的 scheme 是不是 https。注意$http_x_forwarded_proto是读取外部传入的头而$scheme是当前连接协议两者正好可以对照。关于抓包我多提醒一句如果你想观察反代链路抓包位置决定结论。在浏览器到反向代理之间抓看到的应该是 TLS 加密流量能看到 SNI 和证书但看不到明文在反向代理到后端之间抓看到的才是 HTTP 明文。有些人抓了内网段看到一堆 http误以为自己的 HTTPS 被降级了其实是反向代理本来就这么工作。4. 常见坑位速查与真实排查记录4.1 常见降级症状速查表我把平时最常遇到的几种“降级”表现整理成一张表排查时可以按图索骥症状最可能原因排查核心解决办法浏览器地址从 https 被跳到 http后端生成的 Location 用了 http / 80 端口 301 写了 httpcurl 跟踪重定向头传 X-Forwarded-Proto后端信任代理头80 跳转显式写 https页面能开接口全被 Mixed Content 拦掉HTML/JS 里写死了 http 资源浏览器控制台看被 block 的 URL前端资源用相对或动态协议检查 CDN 域名跳转后端口丢失或错误port_in_redirect off / server_name_in_redirect 取错看 Location 头中的端口字段显式写return 301 https://$host:8443$request_uri;访问 http:// 疯转 301 但到不了 https多个 server 块互相代理看 80 与 443 两个配置块80 只保留 return 301下载/安装器报“连接不安全”镜像站反代返回 http 的 302抓 Location 头proxy_redirect 改写或后端处理协议头反代到本地服务出现 502目标端口不是 HTTP 服务 / keepalive 连接复用error log、本地 curl修 proxy_http_version 与后端 listener镜像站、Nexus 仓库这类场景很容易踩表中第四第五行的坑。客户端请求 https 的下载地址反代返回一个 http 的 302很多包管理器和命令行工具要么拒绝跟从要么直接开始走明文下载错误日志里常出现类似000 connection failed或者 502 的提示。大部分时候不是后端挂了而是 Location 的协议写错了。4.2 一次 502 与错误 Location 叠加的排查实录有一次我配一个本地服务Nginx 反代到http://127.0.0.1:1572客户端直接报 “unexpected status 502 bad gateway: unknown error”。那次排查顺序是先撇开反代直接在服务器上curl -v http://127.0.0.1:1572/health发现能通再看 Nginx error.log发现一堆upstream prematurely closed connection。后来发现是 upstream 用了 HTTP/1.1 长连接复用但 Nginx 没有正确配置 keepaliveproxy_set_header Connection 没加导致连接被后端提前关闭。补上proxy_http_version 1.1;和清空 Connection 头之后502 就消失了。这个案例本身不是降级但它提醒我遇到 502 先别急着往证书和 scheme 上想反代与后端之间的连接配置同样会产生误导性的报错。之后我在另一个项目里又遇到类似情况客户端从 https 页面下载 Nexus raw 仓库的文件拿到的链接却是一段http://绝对地址而且路径里还带着内部端口。排查时发现 Nexus 依据的是它自己看到的请求头没收到X-Forwarded-Proto于是它只管按内部地址拼链接。给 Nginx 加上协议透传并用proxy_redirect把内部端口抹掉后一切才恢复正常。4.3 多级代理、Docker 与前端构建的额外注意点如果你的链路是 CDN 到 Nginx再到 Docker 容器里的应用那每一层都要注意协议的传递。CDN 已经终止 TLS 后Nginx 接收到的连接可能只是 http如果 Nginx 再执行proxy_set_header X-Forwarded-Proto $scheme;你传给后端的协议头就变成了 http覆盖掉了客户端真正的 https。这种时候要判断最外层已存在的头可以先把 CDN 传过来的$http_x_forwarded_proto读出来再做一次性覆盖保证后端拿到的永远是整个链路的真实协议。Docker 环境里还有另一个坑容器内应用拿到的 Host 可能是容器名比如web:8000所以proxy_set_header Host $host几乎必不可少。容器里哪怕你外部是 HTTPS反代和容器之间也还是 HTTP这是正常的不要试图在容器里再配一层证书。前端构建时也容易引入协议问题。很多人会把接口地址写死成http://api.example.com一旦页面部署到 https 环境Mixed Content 拦截就来了。更好的做法是接口地址用相对路径或者用window.location.protocol动态拼。尤其是微前端、多域名 CDN 的场景写死协议等于给自己埋雷。发布前用脚本扫一遍产物里的http://比线上被拦截后再救要省事得多。最后说点我个人的习惯。踩过太多次“为什么又变 http”的坑之后我现在做反向代理基本固定一套动作80 端口只留一句 301443 里把X-Forwarded-Proto、X-Forwarded-Host老实传进去后端框架显式信任代理头业务代码里的跳转一律用相对路径或基于当前请求对象拼 URL关键域名上 HSTS 但先用短 max-age 观察一两周没问题再拉长。每次改完配置用 curl 把关键跳转路线打一遍比让用户去浏览器里点一遍靠谱太多。这套流程写在这里希望能帮你省掉几次半夜排障。
返回列表