前言
先纠正一个很常见的误解:nginx 在反代时默认并不"丢弃"客户端请求头,恰恰相反,它会把客户端发来的请求头原样转发给上游。官方文档里proxy_set_header那段只说"默认只重定义了 Host 和 Connection 两个字段",言下之意是其余字段照原样透传。所以"怎么丢弃"这件事从来不是默认行为,而是需要显式配置的动作。
这个默认值带来两个真实后果。第一,任何客户端都能自己塞一个X-Forwarded-For: 1.2.3.4或X-Real-IP进来,如果上游应用信任这类头做审计、限流或 IP 白名单,就形成了伪造漏洞。第二,上游会收到一堆它根本不需要的头(客户端代理留下的Proxy、扫描器探测用的X-Original-URL、以及和上游协议不一致的Connection),轻则日志噪音,重则行为异常。
本文讲清楚三件事:nginx 默认转发了什么、用什么指令把某个头摘掉、以及proxy_set_header那套"全有或全无"的继承规则是怎么把配置写崩的。示例基于 nginx 1.20(RHEL 9 AppStream)/ 1.22(Debian 12)及更新的 1.24、1.26;这些指令在以上版本中语义一致。
一、请求头与响应头是两条独立的通道
新手最容易搞混的一点:改请求头和改响应头用的是完全不同的指令,方向不能错。
| 指令 | 作用方向 | 典型用途 |
|---|---|---|
proxy_set_header | 客户端请求 → 上游 | 改写、追加、丢弃请求头 |
proxy_pass_request_headers | 客户端请求 → 上游 | 一刀切:off时一个客户端头都不转发 |
proxy_hide_header | 上游响应 → 客户端 | 隐藏上游返回的响应头 |
proxy_pass_header | 上游响应 → 客户端 | 默认被藏起来的头(Date、Server 等)放行 |
只记住一句:要处理"客户端发出去的东西",用proxy_set_header。
二、把请求头丢掉,两个层次的手段
2.1 单个字段:把值设成空字符串
这是最精确的做法,也是官方文档明确写的语义——proxy_set_header的值是空字符串时,该字段不会发送给被代理的服务器:
proxy_set_header X-Real-IP ""; proxy_set_header X-Forwarded-For ""; proxy_set_header Proxy "";注意是""(空字符串),不是不写这一行。不写 = 不干预 = 客户端发什么上游收什么;写""= 明确移除。这一字之差就是"防伪造"能不能生效的分水岭。
同理,把值重写成 nginx 自己算出来的真实值,也能达到"丢弃客户端版本"的效果,而且比直接删掉更有用:
proxy_set_header X-Forwarded-For $remote_addr; # 整体替换,客户端塞的值被覆盖这里必须提醒一个广泛存在的错误写法:
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 追加,不是替换$proxy_add_x_forwarded_for的语义是"把客户端 IP 追加到已有的 X-Forwarded-For 后面"。如果客户端发来X-Forwarded-For: 9.9.9.9,上游收到的是9.9.9.9, 真实IP。真实的那个在最后,而绝大多数应用取的是第一个——于是伪造成功。要么改用$remote_addr,要么确认上游取的是最后一个值。
2.2 全量:一个客户端头都不转发
如果上游是纯粹的内部服务,不需要任何客户端信息,可以用总开关:
location /internal-api/ { proxy_pass http://backend; proxy_pass_request_headers off; # 默认是 on }注意off之后,下面这些proxy_set_header依然生效——它们定义的是发给上游的头,与客户端是否转发无关:
location /internal-api/ { proxy_pass http://backend; proxy_pass_request_headers off; proxy_set_header Host $host; # 仍然会发给上游 proxy_set_header X-Real-IP $remote_addr; # 仍然会发给上游 }也就是说,proxy_pass_request_headers off加几条proxy_set_header,是"给上游一个干净、已知的请求头集合"的稳定写法。
三、继承规则:一个 location 打翻整个 server
这是本节最值得反复看的一条。proxy_set_header的继承是全有或全无:
这些指令仅在当前层级没有定义任何 proxy_set_header 时,才继承上一层的配置。也就是说,你在location里写了哪怕一条proxy_set_header,server和http层写的所有proxy_set_header就全部失效,取而代之的是 nginx 的内建默认值:Host $proxy_host、Connection close。
一个非常典型的翻车现场:
server { server_name api.example.com; proxy_set_header Host $host; # 打算让上游知道真实域名 location /v1/ { proxy_pass http://127.0.0.1:8080; proxy_set_header X-Request-Id $request_id; # 只加了一条 # 结果:Host 变成 $proxy_host,也就是 "127.0.0.1:8080" } }上游拿到的 Host 变成了127.0.0.1:8080,基于域名做路由或签名的应用立刻报错。修法只有两种:要么在这个 location 里把需要的东西全部重写一遍,要么把proxy_set_header提到一个所有 location 都能继承的层级上,并且保证 location 里一条都不写。
出于这个原因,实践中推荐把所有proxy_set_header集中定义在一处(http或server层),location 里只写proxy_pass。需要差异化的地方,用include引入一个完整的头集合片段,而不是补一条。
理解Host三种取值也是必要的:
| 变量 | 含义 |
|---|---|
$host | 请求行或 Host 头里的主机名,已转小写、去掉端口;取不到时回退到server_name |
$http_host | 客户端发来的 Host 头原样,带端口 |
$proxy_host | proxy_pass里写的主机名;用 upstream 块时是 upstream 的名字 |
实战:一份可直接用的反代头配置
以下配置以 Debian 12(nginx 1.22,站点文件在/etc/nginx/sites-enabled/)为例;RHEL 9 把server块放进/etc/nginx/conf.d/api.conf即可,其余一样。
# /etc/nginx/conf.d/api.conf upstream api_backend { server 127.0.0.1:8080; keepalive 32; # 配合下面 Connection "" 复用长连接 } server { listen 80; server_name api.example.com; # 关键:所有 proxy_set_header 集中在 server 层, # 保证下面每个 location 都不再单独定义,继承才生效 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 $scheme; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Request-Id $request_id; # 丢掉客户端可能塞进来的、上游不该信的头 proxy_set_header X-Forwarded-Server ""; proxy_set_header X-Original-URL ""; proxy_set_header X-Rewrite-URL ""; proxy_set_header Proxy ""; location /api/ { proxy_pass http://api_backend; # 注意:这里一条 proxy_set_header 都不能写, # 写了就会丢掉上面全部继承来的设置 } location /healthz { proxy_pass http://api_backend; } }如果某个路径确实需要长连接到上游(keepalive生效的前提),Connection头要单独处理:
location /api/ { proxy_pass http://api_backend; # 这里必须写 proxy_set_header,代价是放弃继承,所以把需要的都列全 proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header Connection ""; # 空值 = 删除,长连接才能复用 }而 WebSocket 场景需要相反的值,用map按客户端的Upgrade头动态决定:
# 放在 http 块里 map $http_upgrade $connection_upgrade { default upgrade; '' close; }然后在 location 里写proxy_set_header Connection $connection_upgrade;。注意Connection: upgrade和Connection: ""是互斥的两个需求:前者要升级协议,后者要复用上游长连接,同一个 location 里不能既要又要。
验证:让上游把收到的头打印出来
不要靠猜,直接看 nginx 究竟发了什么。用一个裸 socket 当上游,它会原样打印收到的请求行和请求头:
# 终端 A:监听 8080(nc.openbsd 语法;部分系统是 nc -l -p 8080, # 以本机 nc 的手册为准) nc -l 8080 # 终端 B:发一个带自定义头的请求 curl -s -o /dev/null -H 'X-Secret: 1' -H 'X-Forwarded-For: 9.9.9.9' \ -H 'Host: api.example.com' http://127.0.0.1/api/ping终端 A 里会看到上游实际收到的完整请求头。逐条核对X-Forwarded-For是不是只剩你的真实 IP、X-Secret是否还在、Host是不是api.example.com。改完配置后务必先校验再重载:
sudo nginx -t && sudo systemctl reload nginx sudo nginx -T | less # 打印合并后的完整配置,确认没有别的文件覆盖了你的设置nginx -T这一步很重要,因为conf.d和sites-enabled里其它文件的server块可能匹配同一个server_name,把配置抢先接管。
常见坑点
1. 把X-Forwarded-For用追加变量设置❌proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;——客户端伪造的值留在最前面。 ✅ 用$remote_addr整体替换;确实需要保留链路时,确认上游取的是最后一个地址。
2. 在 location 里只补一条proxy_set_header❌ 在server层定义了Host $host,在location里又写了proxy_set_header X-Request-Id $request_id,结果 Host 变回$proxy_host。 ✅ 继承是全有或全无。要么该层级把需要的头列全,要么在这一层一条都不写。
3. 用proxy_set_header Host $http_host却没意识到差异❌$http_host会把客户端发来的端口一起带上,客户端发Host: evil.com时上游就收到evil.com。 ✅ 明确要知道真实域名就用$host(会回退到server_name);需要原样透传时才用$http_host,并确保server_name严格匹配。
4. 把请求头和响应头的指令用反❌ 想隐藏上游返回的Server头,却写了proxy_set_header Server "";,客户端依旧看到上游的版本号。 ✅ 响应头要用proxy_hide_header Server;。默认被隐藏的响应头有Date、Server、X-Pad、X-Accel-...,需要放行时用proxy_pass_header。
5. 带下划线的头被静默丢弃(反向的坑)❌ 上游需要一个X_Api_Key这样的头,nginx 无论怎么配都不转发。 ✅ nginx 默认丢弃请求头里含下划线的字段。真要放行必须显式开underscores_in_headers on;(放在http或server层)。不过更该做的是把上游的头名改成连字符形式。
6.Connection: ""和 WebSocket 的upgrade冲突❌ 为了上游 keepalive 写了proxy_set_header Connection "";,同一台机器上的 WebSocket 路径握手一直返回 400。 ✅ 用map $http_upgrade $connection_upgrade分流,或者给 WebSocket 单开一个 location 并写全所有头。
7. 想丢掉某条头却写成了"不写"❌ 以为"不在配置里提这个头,它就不会被转发"。 ✅ 不写等于透传。要移除必须显式赋空值:proxy_set_header Proxy "";。
8. 用第三方模块的指令却没装模块❌ 抄来more_clear_input_headers 'Cookie';直接报unknown directive,因为这是headers-more-nginx-module提供的,默认编译的 nginx 里没有。 ✅ 只用内置指令:清 Cookie 用proxy_set_header Cookie "";。要确认手里的 nginx 支持哪些模块,用nginx -V看 configure arguments。
总结
| 目标 | 写法 |
|---|---|
| 移除某个请求头 | proxy_set_header 头名 ""; |
| 用真实值覆盖客户端伪造值 | proxy_set_header X-Forwarded-For $remote_addr; |
| 完全不转发客户端头 | proxy_pass_request_headers off; |
| 上游长连接复用 | proxy_http_version 1.1;+proxy_set_header Connection ""; |
| WebSocket 升级 | map $http_upgrade $connection_upgrade+Connection $connection_upgrade |
| 隐藏上游响应头 | proxy_hide_header 头名; |
| 放行下划线头名 | underscores_in_headers on; |
| 验证实际转发的头 | 裸nc当上游抓原始请求,或nginx -T看最终配置 |
核心只有两句话:nginx 默认透传客户端请求头,要丢就显式赋空值;proxy_set_header的继承是全有或全无,同一层级要么写全要么一条不写。把这两条记住,再配上"改完先nginx -t再用nc抓一次真实请求"的习惯,代理层的头问题基本都能在发布前就拦住。