server_name _ 的代价:nginx 变量语义,和一个 Lua WAF 的 403

需求很简单:给一台机器开一个「用 IP + 端口访问」的入口,反代到本机的一个容器。按习惯写了:

server {
    listen 888 default_server;
    server_name _;
    location / { proxy_pass http://127.0.0.1:21080; ... }
}

nginx -t 过了,reload 也正常。然后所有请求 403。

从响应头里揪出中间层

403 页面的模板是 nginx 的风格,但响应头里带着一个 Set-Cookie: server_session_30288668=…。这个 cookie 名我在应用的正常响应里从来没见过——它不属于后端,也不像 nginx 自己会发的东西。

那就顺着它找:

grep -rl 'server_session' /www/server/ 2>/dev/null

命中一个文件:宝塔 WAF(btwaf)的 public/public.lua。也就是说 403 是这层 Lua 发的,不是 nginx 默认行为,也不是后端。

四处对照,锁定一行

WAF 里判 403 的地方在 waf.lua:

if ngx.ctx.server_name == "未绑定域名" then
    ngx.exit(403)
end

ngx.ctx.server_name 的来源在 public.lua 的 get_server_name_waf():它取的是 ngx.var.server_name,然后——

local c_name = ngx.var.server_name
...
if c_name == '_' then
    c_name = "未绑定域名"
end

四个对照实验确认这条因果链,而不是猜:

  1. curl -H 'Host: www.cccn.xyz' http://127.0.0.1:888/ → 403(换 Host 没用)
  2. curl http://127.0.0.1:21080/ 直连容器 → 200(后端是好的)
  3. 把 server_name 改成真实域名、default_server 保留 → 同样的 curl 立刻 200
  4. 同一份配置在 888 之外另开一个端口,行为完全一致

被踩中的那个语义

$server_name 不是 Host 头。它是「被匹配到的那个 server 块里,server_name 指令写了什么」。

所以 server_name _; 的块,无论请求带什么 Host,$server_name 永远是 _。而 _ 在 nginx 里没有任何魔法——它只是一个永远匹配不上的字面量,是社区约定俗成的「这里没名字」。

任何拿 $server_name 做判断的东西都会把 _ 当成一个真的叫 `_` 的站点:WAF 的白名单、按站点的配置查找、访问日志里的站点标识、缓存 key。这次撞上的就是第一个。

改法:把两件事拆开

server {
    listen 888 default_server;      /* 决定:Host 不匹配时谁接 */
    server_name www.cccn.xyz;       /* 决定:$server_name 是什么 */
    ...
}

default_server 和 server_name 是两个正交的东西。前者管兜底,后者管”我是谁”。用 IP + 端口访问时,Host 是 1.2.3.4:888,匹配不上任何 server_name,于是落到 default_server 这个块——但 $server_name 仍然是有名字的域名,WAF 放行。

顺带一个副作用值得知道:这个块成了该端口的默认站点后,访问日志、任何按 $server_name 归类的统计,都会记在真实域名名下而不是 IP 名下。对排查来说是好事。

我现在排 403 的顺序

  1. 先看响应里有没有不属于任何应用层的痕迹:奇怪的 cookie 名、Server 头、错误页模板的字数。有就顺着字符串 grep 整台机器。
  2. 对比”经过反代”和”直连后端”两条路的响应码。后端 200、前面 403,问题必在中间。
  3. 最后才怀疑 deny、allow、防火墙。这次从头到尾 IP 都不是因素,WAF 拦的是名字,不是地址。
← 返回技术笔记