反向代理下的重定向:Location 为什么会变成 http://host:8081/
登录成功却跳到一个打不开的地址,Location 里带着内网端口甚至 localhost。这类问题几乎都出在三个地方:绝对 Location、漏传 X-Forwarded-*、以及 location 补尾斜杠产生的 301。
「接口文档里写的地址是 https://site.example,为什么浏览器跳过去变成了 http://site.example:8081/xxx/?」
这类问题在多层反向代理的环境里出现频率极高,而且代码看起来完全没问题。原因基本都出在三个地方:绝对 Location、漏传 X-Forwarded-*、以及 nginx 给地址补尾斜杠产生的隐藏 301。
一、相对 Location 与绝对 Location
HTTP 的 Location 允许两种写法:
Location: /console ← 相对,浏览器按当前 origin 解析
Location: https://site.example/console ← 绝对,原样使用
相对路径的好处是后端不需要知道自己在哪个域名、哪个端口后面。缺点是有些框架/场景不方便——比如从 http 跳到 https 时必须给绝对地址。
反向代理下的原则很简单:
能用相对就用相对;必须用绝对时,绝对地址必须来自配置,不能来自请求头推导。
二、后端为什么会「自己拼」绝对地址
大部分框架在生成绝对 URL 时,会看两个东西:Host 头和 X-Forwarded-* 系列。代理层只要有一个没传对,就会拼出内网地址。
| 头 | 作用 | 漏了会怎样 |
|---|---|---|
Host |
对外域名与端口 | 生成 http://localhost:3000/... |
X-Forwarded-Proto |
原始协议 | https 站点生成 http://... |
X-Forwarded-Host |
原始 host(部分框架优先读它) | 生成内网 host |
X-Forwarded-For |
客户端真实 IP | 日志与限流全部记成代理 IP |
X-Forwarded-Port |
原始端口 | 生成带内网端口的地址 |
一份够用的代理头配置:
location / {
proxy_pass http://127.0.0.1:3002;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
}
X-Forwarded-For 用 $remote_addr 还是 $proxy_add_x_forwarded_for,取决于你是「覆盖式传出」还是「追加」。入口层应该是覆盖式:客户端自己带进来的 XFF 不可信,直接丢弃。
三、nginx 的两类隐藏 301
1. 补尾斜杠
当 location 是 /admin/ 这种以斜杠结尾的前缀,而请求是 /admin 时,nginx 会:
- 先尝试用
location /处理,如果命中的是目录/代理,往往由后端决定; - 但如果命中的是
root/alias指向目录,nginx 会主动 301 到/admin/。
而 301 的 Location 是绝对地址,由 $scheme、$host、server_port 拼出来。中间层如果 host 被改写成 ispt.top:8081,就会得到:
HTTP/1.1 301 Moved Permanently
Location: http://ispt.top:8081/admin/
浏览器去连 8081,而 8081 只监听 127.0.0.1——「找不到网页」。
2. proxy_pass 带不带 URI
这一条不产生 301,但会让跳转「看起来没生效」:
# 不带 URI:原样转发,/gitea/foo → 后端收到 /gitea/foo
location /gitea/ { proxy_pass http://127.0.0.1:23000; }
# 带 URI:前缀被替换,/gitea/foo → 后端收到 /foo
location /gitea/ { proxy_pass http://127.0.0.1:23000/; }
差别就在结尾那个 /。后端如果是按根路径设计的(比如 Gitea、Portainer),必须用第二种;否则所有静态资源都会 404。
四、这次的真实链路
站点是四层结构,每一层都可能改写地址:
浏览器
│ https://ispt.top/console
▼
443 入口(TLS 终结) proxy_set_header Host $http_host;
│ X-Forwarded-Proto https
▼
8080 WAF(BunkerWeb)
│
▼
8081 安全层(nginx) ← 这里配置了约 30 条 location
│ Host 用的是 $host,port 丢了
▼
ispt-app:3002(Next.js) ← 303 Location: /console
问题出在 8081:它自己认为「我叫 ispt.top:8081」,所以任何需要补全的绝对地址都带着 :8081。而 8081 对外不可见。
修复分两步:
- 应用侧不再产生绝对地址:登录成功返回
303 Location: /console,由浏览器按当前 origin 解析; - 管理端的对外入口收口到环境变量:如果确实需要给绝对地址(比如发邮件里的链接、
/studio兼容跳转),只读ADMIN_PUBLIC_URL。
const ADMIN_URL = process.env.ADMIN_PUBLIC_URL || '/console';
五、排查方法
1. 只看一跳,不要跟随
curl -sI https://site.example/login | grep -iE '^(HTTP|location)'
想看完整链路再用 curl -sIL,把所有跳转打印出来:
curl -sIL https://site.example/admin 2>&1 | grep -iE '^(HTTP|location)'
2. 确认哪一层在改写
按顺序对每一层直连(都在服务器上跑):
curl -sI http://127.0.0.1:3002/admin | grep -i location # 应用本身
curl -sI http://127.0.0.1:8081/admin | grep -i location # 安全层
curl -sI http://127.0.0.1:8080/admin | grep -i location # WAF
curl -sI https://site.example/admin | grep -i location # 入口
哪一层的 Location 开始变样,问题就在那一层。
3. 别用 -L 调试
curl -L 会自动跟随,最后只看到终点的状态码,中间所有 301/302 的信息都丢了。排查跳转问题的第一步永远是关掉跟随。
六、工程上的收口
三条规则,能挡掉绝大多数同类问题:
- 重定向只写相对路径。后端不知道自己在几层代理后面,猜就是错。
- 绝对地址只有一个来源:环境变量。换域名、加前缀、临时走端口时只改配置,不改代码。
- 入口层统一注入代理头,下游层不要各自
proxy_set_header Host $host反复改写——每改一次就丢一点原始信息。
小结
Location能用相对就别用绝对;绝对地址必须来自配置;- 反向代理下出现内网 IP、内网端口、
localhost的 URL,先查Host与X-Forwarded-*; - nginx 给
location /X/补尾斜杠产生的 301 是绝对地址,这是最常见的「莫名多出个端口」来源; proxy_pass结尾那个斜杠决定前缀是否被剥掉,静态资源 404 优先怀疑它;- 排查永远从「逐层直连 + 只看一跳」开始,不要靠猜。
正在加载评论…