拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux Nginx 怎么在代理时丢弃不需要的客户端请求头

Linux Nginx 怎么在代理时丢弃不需要的客户端请求头

前言

先纠正一个很常见的误解: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_hostproxy_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抓一次真实请求"的习惯,代理层的头问题基本都能在发布前就拦住。

返回列表