一、一个 Host 头为什么会惹出这么多事
先说结论:客户端发来的 Host 头是用户输入,和 URL 里的参数、表单里的字段没有任何区别。它只在一种情况下可信——你亲手确认过它经过的每一跳都会把它改写或丢弃。而现实中,从浏览器到源站要经过 CDN、负载均衡、反向代理至少三层,每一层都可能原样透传,也可能悄悄改掉。
我遇到这个问题是在一次密码重置的排障里。用户点了重置邮件里的链接,页面打开后跳到了一个完全陌生的域名。查了半天代码,链接本身是对的,问题出在生成链接的那行代码:
$url = 'https://' . $_SERVER['HTTP_HOST'] . '/reset?token=' . $token;攻击者只要在发重置请求时把 Host 头改成自己的域名,服务器就老老实实把带有 token 的链接指向他,用户点开 token 就落到别人手里。这类问题在漏洞库里叫 Host header injection,症状却常常表现为“用户说收到了一封奇怪的邮件”,很容易被当成钓鱼邮件放过去。
二、三种典型后果,先认清危害再谈防护
缓存投毒:最隐蔽的一种
反向代理和 CDN 通常按“URL + Host”做缓存键,但很多配置里缓存键只取 URI。这意味着攻击者带一个伪造 Host 请求一次,响应里写满了绝对 URL(重定向的 Location、页面里的静态资源、og:url 这类 meta),这个响应被缓存下来,接下来所有访客拿到的都是指向攻击者域名的页面。
这种攻击的可怕之处在于它不需要用户配合,一次请求污染,全站受害,而且从日志里看不出异常——后续受害者的请求本身是完全合法的。
密码重置与邮件链接劫持
上面那个例子就是这一类。凡是“服务端根据请求推导出绝对链接”的地方都是入口:密码重置、邮件验证、支付回调、Webhook 回填、SSO 的 redirect_uri 校验。不少框架默认信任 Host,只在生产环境配置了 trusted_hosts 才开始校验。
路由错乱:多站同 IP 时的连带事故
一台机器上跑十个虚拟主机时,Nginx 用 Host 选 server 块。如果请求的 Host 匹配不上任何 server_name,Nginx 会把请求交给该监听端口的第一个 server 块(默认服务器)。默认服务器若是某个客户的站点,别人把域名解析过来就能白用你的资源,还带着你的证书。
三、Nginx 侧的第一道闸:把默认服务器做成垃圾桶
最省事也最有效的做法,是给每个监听端口显式指定一个什么都不做的默认 server:
server {
listen 80 default_server;
listen 443 ssl default_server;
server_name _;
ssl_certificate /etc/nginx/ssl/dummy.crt;
ssl_certificate_key /etc/nginx/ssl/dummy.key;
return 444;
}几个要点:
return 444是 Nginx 特有的“直接关闭连接不发响应”,比 403 更省事,也不会给对方留下可以探测的响应体。- 443 上的默认服务器必须配一张证书,哪怕是自签的。因为 TLS 握手发生在读到 HTTP 请求之前,没有证书就无法完成握手,SNI 不匹配的请求会直接失败,表现为“连接被重置”而不是一个清楚的错误,排查时容易被误判成网络问题。
- 每个需要对外服务的 server 块都要写全
server_name,包括带 www 和不带 www 两种写法,以及内部的监控探活用的 IP 或主机名——否则健康检查会被自己的默认服务器拒掉。
验证方式很直接:拿一个没配过的域名指向这台机器,看是超时还是 444;再确认带正确 Host 的请求照常返回 200。
四、应用侧:别再用 HTTP_HOST 拼链接
这一条比 Nginx 配置更重要,因为 Nginx 拦不住的场景太多了(CDN 改了 Host、应用直接暴露在内网、上层还有一层网关)。原则只有一句话:站点根地址必须来自配置,不能来自请求。
// 错:从请求里推导
$base = 'https://' . $_SERVER['HTTP_HOST'];
// 对:来自配置文件,且配置在部署时写入
$base = rtrim($config['site_url'], '/');PHP 的常见框架各有对应开关:Laravel 的 TrustHosts 中间件、Symfony 的 trusted_hosts、WordPress 里有人用 WP_HOME / WP_SITEURL 固定住。不管哪个,检查清单是一样的:
- 搜索代码里所有
HTTP_HOST、SERVER_NAME、X-Forwarded-Host的使用处,逐个判断是不是在拼绝对链接; - 框架的受信主机列表要写具体域名,别留空,也别写通配
*; - 反向代理传进来的协议要用
X-Forwarded-Proto,但前提是代理是可信的——这条链路如果对外暴露,同样可以被伪造,后果是“http 链接被生成在 https 站点上”,更常见的是重定向死循环。
五、CDN 与代理层:缓存键和 Host 转发要一起看
主机头攻击的破坏力,一半来自缓存。所以 CDN 的配置要单独过一遍:
# 缓存键必须包含 Host(大多数 CDN 默认如此,但要确认)
# Nginx 作为缓存层时的写法
proxy_cache_key "$scheme$host$request_uri";
proxy_cache_bypass $http_upgrade;
proxy_ignore_headers Set-Cookie;另外两件事经常被忽略:
- 回源 Host 的固定。CDN 回源时如果把用户请求的 Host 原样带给源站,而源站是多站点,就可能落到错误的 server 块。回源 Host 应该固定为源站的真实域名。
- 只缓存敢缓存的响应。带
Location头且状态码是 301/302 的响应,除非你确认它的目标地址是配置常量生成的,否则不该进缓存。Cache-Control: no-store比重试更可靠。
六、验证:三条命令确认防护真的生效
配置写完不验证等于没写。这套测试我在三个站点上都跑过,全部通过才算收口:
# 1. 伪造 Host,看是否被默认服务器关掉
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: evil.example.com' http://你的IP/
# 期望:000(连接被关闭)或 444/403,绝不是 200
# 2. 看响应里有没有绝对 URL 泄露真实域名
curl -s -H 'Host: evil.example.com' http://你的IP/ | grep -oE 'https?://[^"'"'"' ]+' | sort -u | head
# 3. 缓存层:带同一 URI 不同 Host 各请求一次,比对响应头
curl -sI -H 'Host: 你的域名' https://你的域名/page
curl -sI -H 'Host: evil.example.com' https://你的域名/page
# 两张响应头应当明显不同(第二张应该是错误),若完全一致则缓存键可能不含 Host第一条命令最容易出意外:很多人会拿到 200 却以为是自己的防护写错了,实际是 IP 直连落到了某个 server 块上——那正说明“默认服务器”没有做隔离,需要回头补第三节那段配置。
七、内部服务的 Host 校验:别只做外网加固
对外收口之后,内网往往还是敞着的。Kubernetes 的 Ingress、内部的 Grafana、Jenkins、GitLab,默认都接受任意 Host。主机头攻击在内部能干什么?DNS rebinding 就是其中一种:攻击者的页面用 JS 让受害者的浏览器去请求内网服务,绕开同源策略的直觉判断,读走内网的 API 响应。
所以内网服务也要有一条统一原则:要么做 Host 白名单,要么用 token 认证。Kubernetes 里给 Ingress 写死 host 字段就够了;裸机上的服务,Nginx 那层默认服务器配置照样适用。
八、小结:三条规则,一条都不能省
回头看这一整套,核心其实是三句话:Host 是用户输入;绝对链接来自配置;默认服务器要改成垃圾桶。这三条与具体的语言、框架、CDN 厂商都无关,换到任何一套技术栈上都能照搬:
- 第一,Nginx 每个端口有且只有一个
default_server,返回 444,443 上也要有证书; - 第二,代码里所有拼绝对 URL 的地方都改成读配置,框架的受信主机列表要写具体域名;
- 第三,缓存键必须包含 Host,回源 Host 要固定,301/302 响应不进缓存。
最后提醒一句:这类问题的排查最忌讳“先改代码再说”。先用第六节的三条命令做一次基线探测,把现状记下来,改完再跑一遍对比。基线是唯一可靠的进步证据,没有基线,你会永远在怀疑是不是改错了。
一个常被忽略的入口:X-Forwarded-Host 与 TLS 终止
把 Host 校验做严之后,还有一条路要堵:很多应用不是读 Host,而是读 X-Forwarded-Host。这两个头在语义上不同——前者是客户端直连的那一层看到的,后者是代理告诉你的“原始 Host”。问题是 Nginx 用 proxy_set_header 传这个值时,通常直接写 $host,而 $host 本身来自请求,于是伪造链条原封不动地穿过代理。
正确的写法是在代理层做一次规范化,把不可信的值丢掉,只传你认可的那个:
# 可信代理链路上,$host 已被 default_server 过滤过,才可以往下传
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
# 应用内部再兜底一次:白名单之外的 X-Forwarded-Host 一律视为不存在
map $http_x_forwarded_host $safe_host {
default $host;
你的域名 $http_x_forwarded_host;
www.你的域名 $http_x_forwarded_host;
}另一个连带问题是 TLS 终止的位置。HTTPS 在 CDN 上终结、回源走 HTTP 的架构里,源站看到的 $scheme 永远是 http,如果应用靠它判断“当前是不是 HTTPS”,就会做出错误的重定向,轻则多一跳,重则死循环。判据是看回源请求头里有没有 X-Forwarded-Proto:有就据它判断,没有就一律按配置里的固定值处理,别依赖 $scheme。
