Nginx location 匹配优先级:一次 /admin 被别的服务抢走的复盘

登录成功后跳到 /admin,结果落进了隔壁业务的后台,另一些时候干脆「找不到网页」。查了一晚上才发现是三条规则叠在一起:最长前缀匹配、^~ 的截断、以及 location 补尾斜杠产生的 301。

登录成功,页面却跳到一个打不开的地址;有时候更诡异——直接落进了隔壁业务的后台。

这次排查花了一晚上,最后发现不是代码问题,也不是代理没配,而是 nginx 的三条规则叠在了一起:最长前缀匹配、^~ 的截断行为、以及 location /X/ 自动补尾斜杠产生的 301。

一、现象:跳转目标被半路截胡

站点是这么部署的:一台机器上跑着好几个业务,443 只有一个入口,靠路径前缀分给不同后端。

443 ──► WAF ──► nginx 安全层 ──┬─ /admin/     → 业务 A(别人的后台)
                              ├─ /platform/  → 业务 B
                              └─ /          → 我们的站点

我们的管理端原来挂在 /admin。点登录,服务端返回:

HTTP/1.1 303 See Other
Location: /admin

浏览器跟过去,落到的是业务 A 的登录页;而不带尾斜杠的 /admin 更糟,直接 301:

HTTP/1.1 301 Moved Permanently
Location: http://ispt.top:8081/admin/

8081 是只监听 127.0.0.1 的安全层。浏览器在自己机器上找 8081,当然「无法访问此网站」。

两个现象、同一个根因:/admin 这个命名空间早就被别人占了。

二、location 的写法与真实优先级

先把规则摆清楚。nginx 的 location 有五种写法,优先级不是按书写顺序:

写法 类型 何时使用
location = /path 精确匹配 一个具体地址,不做前缀扩散
location ^~ /path/ 前缀匹配 + 截断 整个子树都给这个后端,且不希望被正则抢
location ~ \.php$ 正则(区分大小写) 按后缀、按模式分流
location ~* \.png$ 正则(不区分) 同上,忽略大小写
location /path/ 普通前缀 最常见,按最长前缀决定

匹配顺序:

  1. 先找 = 精确匹配,命中即止;
  2. 再按最长前缀找普通前缀与 ^~;
  3. 如果最长命中的那个是 ^~,直接用,不再看正则;
  4. 否则按配置文件中出现的顺序,逐个尝试正则,第一个命中的胜出;
  5. 正则都不命中,就用第 2 步记下的最长前缀。

容易记错的点:

  • 正则优先于普通前缀,但 ^~ 能截断正则;
  • 普通前缀之间比的是长度,不是书写先后;
  • location /admin/ 与请求 /admin 是不同的前缀匹配——前者不匹配后者。

最后这条就是事故的起点。

三、/admin 为什么会被别人接走

别人的配置是:

location /admin/ {
    proxy_pass http://127.0.0.1:23100;
}

我们请求 /admin(无尾斜杠):

  • location /admin/ 需要路径以 /admin/ 开头,/admin 不满足;
  • 于是落到 location /,也就是我们的站点;
  • 我们返回 303 到 /admin,浏览器又发一次 /admin,再落到 location /……听起来应该死循环,但实际看到的是一条 301。

原因在下一跳:安全层的 server 块上配了 port_in_redirect 与绝对重定向的习惯,/admin 被当成「目录」补了尾斜杠,于是 301 到 /admin/,并且因为这一层认为自己的 host 是 ispt.top:8081,Location 就被拼成了绝对地址。

链路完整地走一遍:

浏览器 GET /admin
  → 443 入口 location /        (转 8080)
  → 8080 WAF                   (转 8081)
  → 8081 location /            (转我们的 app)
  → app 303 Location: /admin
  → 浏览器 GET /admin(第二个)
  → 8081 把它当目录,301 Location: http://ispt.top:8081/admin/
  → 浏览器打不开 8081          ← 卡在这里

而如果恰好某一步带上了尾斜杠,/admin/ 就会命中业务 A 的 location,落到别人的后台。

四、怎么确认到底命中了哪条 location

别猜,让 nginx 自己说。三个手段,按成本从低到高:

1. 打印合并后的完整配置

nginx -T 会把 include 全部展开,这是唯一能确认「到底有哪些 location 存在」的方式(很多人只看了主配置,漏掉了 conf.d/ 和 default.d/):

nginx -T 2>/dev/null | grep -nE 'server_name|listen |location '

2. 只看响应头,不跟随跳转

curl -sI https://site.example/admin | grep -iE '^(HTTP|location)'

-I 配 -L 会自动跟随,反而看不出中间跳了几次。要跟的话用 curl -sIL 并把每一步都打印出来。

3. 临时打标记

在可疑的 location 里各加一行(always 保证错误响应也带):

location /admin/ {
    add_header X-Hit "admin-subtree" always;
    proxy_pass http://127.0.0.1:23100;
}

注意:location 内出现任何 add_header,就不再继承 server 层的那些头。所以这个手段只适合临时排查,查完要删掉,否则 HSTS、CSP 会悄悄消失。

五、正确的做法

1. 别跟别人抢命名空间

路径前缀是共享资源。改之前先 nginx -T | grep location 看一眼,占用了就换一个——我们最后把管理端换成了 /console,管理端在 Next 里的路由目录也一起改名,从此不需要单独端口。

2. 跨端口的跳转目标写进环境变量

管理端的对外地址不是「当前站点 + 一个路径」这么简单(可能换域名、加前缀、甚至临时走端口),所以落点只认一个来源:

// 只在这里定义一次,登录页、登录接口、导航链接都读它
const ADMIN_URL = process.env.ADMIN_PUBLIC_URL || '/console';

3. 需要「整个子树都给后端」时,用 ^~ 把意图写明确

# ^~ 的意思是:这个前缀最长命中后,不要再拿正则来抢
location ^~ /api/ {
    proxy_pass http://127.0.0.1:3002;
}

不加 ^~ 时,一条后来加的 location ~ \.json$ 就可能把 /api/xxx.json 抢走——这种问题在配置越来越大之后非常难查。

4. 兜底 location 用 location /,不要写 location /*

/* 是非法写法,但更常见的是写成 location / 却以为它能拦住所有「更深」的路径。它确实能兜住,但它优先级最低,任何更长前缀都会盖掉它——这正是我们想要的。

小结

  • location 的优先级是:= → 最长前缀(^~ 截断正则)→ 正则按顺序 → 最长前缀兜底;
  • location /admin/ 与 /admin 不是一回事,差一个斜杠就换了主人;
  • 反向代理下出现 http://host:内网端口/... 这种 Location,八成是某一层按自己的 Host/Port 拼了绝对地址;
  • 排查用 nginx -T 展开全量配置 + curl -I 只看一跳,比读代码快得多;
  • 落地经验:共享入口上,路径前缀要当成全局命名空间来管理,跳转目标一律由环境变量提供,不在代码里拼站内路径。
查看「中间件」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

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