Nginx 真实客户端 IP 与代理协议:信任链、real_ip 模块与 PROXY protocol

讲解多层代理下真实客户端 IP 的还原方法,涵盖 X-Forwarded-For 信任链与伪造风险、ngx_http_realip_module 的 set_real_ip_from 配置、PROXY protocol 在四层的传递,以及与访问日志和限流的联动。

只要 Nginx 前面还有一层负载均衡、CDN 或者云厂商的四层转发,$remote_addr 拿到的就不再是真实客户端地址,而是上一跳代理的地址。这个看似微小的偏差会一路污染下游:访问日志里的来源统计失真、基于 IP 的限流把整栋楼的用户合并成一个桶、风控系统拿到的全是同一个内网地址。本文从头部语义讲起,逐层拆解信任链的构建方式,再落到 real_ip 模块与 PROXY protocol 两套还原机制上。

一句话总结: 真实 IP 还原的本质是「确定哪些上游可信、再从可信链上取下第一个不可信地址」,机制本身很简单,难的是信任边界一旦放宽就等于把伪造能力交给了攻击者。

1. 为什么 remote_addr 会失真

一句话总结: Nginx 只认识 TCP 对端地址,任何在其之前的代理都会让 remote_addr 变成上一跳的地址,这既影响日志也影响所有基于 IP 的判断。

$remote_addr 直接取自 ngx_connection_t 的 sockaddr,也就是 TCP 连接的对端地址。当链路是「客户端 → CDN → 负载均衡 → Nginx」时,Nginx 看到的对端是负载均衡的内网地址,与真实用户没有任何关系。

客户端 1.2.3.4
   |
   v  TCP 源地址 1.2.3.4
CDN 节点
   |
   v  TCP 源地址 10.0.0.5(CDN 回源地址)
四层负载均衡
   |
   v  TCP 源地址 10.0.1.9(LB 地址)
Nginx  -> $remote_addr = 10.0.1.9

除了日志统计,受影响的还有三类常见能力:limit_req / limit_conn 的共享内存 key 通常写 $binary_remote_addr,失真后所有用户共用一个桶;allow / deny 的 ACL 判断会失效;应用层风控拿到的 X-Real-IP 也一并错误。因此还原真实 IP 不是可选项,而是多层架构下的必备配置。

Nginx 为这个场景提供了三个互相配合的变量,理解它们的分工能少走很多弯路:

$remote_addr             当前认为的客户端地址,可能被 real_ip 模块替换
$realip_remote_addr      替换前的原始对端地址,用于审计与二次校验
$proxy_protocol_addr     从 PROXY protocol 头中解析出的源地址

还原动作发生时,$remote_addr 被改写,而 $realip_remote_addr 保留原值。把两者同时写进日志,就能在事后区分「谁连的我」与「我认为是谁」,这在排查信任链配置错误时非常有用。

2. X-Forwarded-For 信任链

一句话总结: X-Forwarded-For 是一个由各跳代理依次追加的列表,最左侧是客户端,但整个头部都是客户端可写的,只能信任其中靠右侧由自己代理写入的部分。

2.1 头部的生成与追加规则

一句话总结: 标准做法是每跳把自己的对端地址追加到列表尾部,于是列表从左到右依次是原始客户端与各级代理。

请求经过三层代理后:
X-Forwarded-For: 1.2.3.4, 10.0.0.5, 10.0.1.9

1.2.3.4   客户端真实地址(最左)
10.0.0.5  CDN 回源地址(由 CDN 写入)
10.0.1.9  负载均衡地址(由 LB 写入,即 Nginx 的对端)

Nginx 自身在 proxy_set_header 中配置 X-Forwarded-For $proxy_add_x_forwarded_for;,这个变量会把已有头部与 $remote_addr 拼接起来,实现「追加而非覆盖」。用 $remote_addr 而不是直接覆盖头部,是为了保留上游信息;但这也意味着如果客户端自己伪造了一段头部,Nginx 会忠实地把它保留在最左侧。

2.2 伪造风险与信任边界

一句话总结: 客户端可以任意伪造 X-Forwarded-For,因此绝不能取最左值作为客户端地址,只能从右侧倒数第 N 跳取。

攻击者只需发一个 X-Forwarded-For: 8.8.8.8 的请求,若后端直接取列表第一个元素,就会认为请求来自 8.8.8.8,从而绕过基于 IP 的白名单或者污染统计。正确做法有两种:

# 做法一:由边缘代理强制覆盖,丢弃客户端传入的头部
proxy_set_header X-Forwarded-For $remote_addr;

# 做法二:保留追加语义,但由 real_ip 模块按可信跳数取值
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

做法一适用于「自己完全掌控边缘入口」的场景,代价是丢失中间跳信息;做法二保留完整链路,但必须配合 set_real_ip_from 声明的可信网段,由 Nginx 自己决定从哪个位置取值。混用是最危险的:既保留了客户端头部,又用取最左值的逻辑解析。

3. real_ip 模块配置

一句话总结: ngx_http_realip_module 通过 set_real_ip_from 声明可信来源、real_ip_header 指定取值头部,把 remote_addr 原地替换为真实地址,替换后所有依赖该变量的模块都自动受益。

3.1 set_real_ip_from 与 real_ip_header

一句话总结: 只有连接来源落在 set_real_ip_from 声明的网段内时,头部才会被采信,这是整个机制的安全基石。

# 需在编译时包含 --with-http_realip_module
server {
    listen 80;

    # 声明可信代理网段:只有这些来源发来的头部才被采信
    set_real_ip_from 10.0.0.0/8;
    set_real_ip_from 172.16.0.0/12;
    set_real_ip_from 192.168.0.0/16;
    # 云厂商 CDN 回源段需按其文档逐个补齐
    set_real_ip_from 203.0.113.0/24;

    # 指定从哪个头部取值
    real_ip_header X-Forwarded-For;

    # 取值后是否继续向前递归查找
    real_ip_recursive on;
}

set_real_ip_from 可以出现多次,也支持直接写 IP 或 CIDR。它的判定对象是TCP 对端地址(也就是替换前的 $remote_addr),而不是头部内容,因此攻击者无法通过伪造头部来绕过这层校验。

3.2 real_ip_recursive 的语义

一句话总结: real_ip_recursive on 时从列表最右侧向左跳过所有可信地址取第一个不可信值,off 时只取列表最右侧一个值。

这两种行为差异很大,用一个具体例子说明。假设可信网段包含 10.0.0.0/8,请求头部为:

X-Forwarded-For: 1.2.3.4, 10.0.0.5, 10.0.1.9

real_ip_recursive off 会直接取最右侧的 10.0.1.9——如果这只是内网 LB 地址,还原结果依然是错的;real_ip_recursive on 会从右向左逐个检查,10.0.1.9 与 10.0.0.5 都落在可信网段内被跳过,最终取到 1.2.3.4,这才是正确结果。因此在多层可信代理场景下应始终开启递归。

递归模式下的安全前提是:攻击者无法在可信代理右侧注入内容。如果某跳代理没有覆盖而是追加客户端头部,那么客户端伪造的值会落在列表中偏左的位置;递归查找会跳过自己写入的地址,但仍可能取到伪造值。结论是每一跳都必须遵循「追加自身地址」的规范。

下面这段配置演示了「可信段较窄」时的典型误配,它会让递归取值提前停止:

# 反例:只声明了 LB 地址,但链路中还有一跳 CDN
set_real_ip_from 10.0.1.9;      # 只有 LB 可信
real_ip_recursive on;

# 头部:1.2.3.4, 10.0.0.5, 10.0.1.9
# 从右向左:10.0.1.9 可信跳过 -> 10.0.0.5 不可信,立即停止
# 结果 $remote_addr = 10.0.0.5,仍然是 CDN 回源地址

正确的写法是把链路上每一跳的地址段都列进来,或者干脆把整段内网地址声明为可信,再依赖「边缘代理覆盖写入」来阻断伪造。两者取其一,不要既信任宽网段又保留客户端追加的头部。

4. PROXY protocol

一句话总结: PROXY protocol 在 TCP 层把原始源目地址前置到连接数据之前,让四层代理无需依赖 HTTP 头部即可传递真实地址,代价是两端必须同时支持。

4.1 协议格式与版本

一句话总结: v1 是可读的文本行,v2 是带签名的二进制结构,两者都不加密也不鉴权,只应在可信网络内使用。

v1(文本,人眼可读):
PROXY TCP4 1.2.3.4 10.0.1.9 51234 443\r\n

v2(二进制,带魔数与长度):
\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A
+ 版本命令字节 + 地址族 + 地址与端口 + 可选的 TLV 扩展

PROXY protocol 的价值在于它工作在四层,因此对 HTTP、gRPC、WebSocket 一视同仁,甚至对数据库协议也适用。它同时解决了 X-Forwarded-For 在非 HTTP 协议中无处安放的问题。代价是接收端必须显式开启,否则会把协议行当作应用数据解析而报错。

4.2 Nginx 两侧配置

一句话总结: 发送端用 proxy_protocol on 打开,接收端用 listen … proxy_protocol 声明,两者必须成对出现。

# 发送端(stream 模块中,把连接转发给后端 Nginx 时带上 PROXY 头)
stream {
    upstream backend_nginx {
        server 10.0.2.10:8443;
    }

    server {
        listen 443;
        proxy_pass backend_nginx;
        proxy_protocol on;
    }
}
# 接收端:listen 上声明 proxy_protocol,并据此还原地址
server {
    listen 8443 proxy_protocol;
    set_real_ip_from 10.0.2.0/24;
    real_ip_header proxy_protocol;

    location / {
        proxy_pass http://app_backend;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

开启 proxy_protocol 后,该端口的 $remote_addr 会被协议头中的源地址替换。需要注意的是,接收端若未声明而发送端已开启,握手阶段就会失败,典型表现是浏览器报 ERR_EMPTY_RESPONSE、Nginx 日志出现 broken header。反过来,接收端声明了但发送端没开,则所有连接都会因解析失败而被拒绝。

5. 日志与限流联动

一句话总结: 还原后的 remote_addr 会被所有模块共用,因此日志格式、限流 key、ACL 都能自动获得真实地址,前提是 real_ip 配置位于同一 server 或更上层。

log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                '$status $body_bytes_sent "$http_referer" '
                '"$http_user_agent" rt=$request_time '
                'xff="$http_x_forwarded_for"';

# 按真实 IP 限流:key 使用 $binary_remote_addr 而非头部
limit_req_zone $binary_remote_addr zone=perip:10m rate=20r/s;
limit_conn_zone $binary_remote_addr zone=connperip:10m;

server {
    listen 80;
    set_real_ip_from 10.0.0.0/8;
    real_ip_header X-Forwarded-For;
    real_ip_recursive on;

    location /api/ {
        limit_req zone=perip burst=40 nodelay;
        limit_conn connperip 20;
        proxy_pass http://api_backend;
    }
}

限流 key 用 $binary_remote_addr 而不是 $http_x_forwarded_for 的原因有两点:前者是二进制定长(IPv4 四字节、IPv6 十六字节),共享内存占用固定且哈希快;后者是任意长度字符串,既浪费内存又容易被超长头部打爆。同理,limit_conn 也应基于还原后的地址。日志中同时保留原始 $http_x_forwarded_for 便于事后审计链路。

如果业务需要按「真实 IP 但保留原始对端」做双层限流(例如防止某个代理整体过载),可以用 map 组合出复合 key:

map $realip_remote_addr $edge_bucket {
    default        $realip_remote_addr;
}

limit_req_zone $edge_bucket zone=peredge:10m rate=500r/s;

server {
    listen 80;
    set_real_ip_from 10.0.0.0/8;
    real_ip_header X-Forwarded-For;
    real_ip_recursive on;

    location /api/ {
        limit_req zone=peredge burst=1000 nodelay;
        proxy_pass http://api_backend;
    }
}

这样一层按真实用户限速、一层按入口代理限速,可以同时防住「单用户刷接口」与「某个代理整体异常」两类问题。注意 $realip_remote_addr 在未配置 real_ip 时等于 $remote_addr,因此这段配置在没有代理的直连场景下也安全。

6. 边界场景与常见坑

一句话总结: 大部分翻车来自信任网段过宽、多跳头部拼接错误、以及 CDN 场景下回源 IP 段变更未同步。

常见问题可以归纳为以下几类:

[现象] 限流把全部用户当成一个人
[原因] limit_req_zone 的 key 写成了 $http_x_forwarded_for 但未配 real_ip
[修复] 改用 $binary_remote_addr 并补齐 set_real_ip_from

[现象] 内网扫描器伪造 XFF 触发风控误封
[原因] 只信任了负载均衡地址,但负载均衡并未覆盖客户端头部
[修复] 在边缘代理改为覆盖写入,或收紧 real_ip_recursive 的使用

[现象] IPv6 用户全部被识别为同一个地址
[原因] set_real_ip_from 只写了 IPv4 网段
[修复] 补上 ::1/128 与对应的 IPv6 内网段

[现象] 切换 CDN 后真实 IP 全变成 CDN 回源段
[原因] 新 CDN 的回源网段未加入 set_real_ip_from
[修复] 用配置管理把网段清单集中维护并定期同步

另一个容易忽略的点是 real_ip_header 的取值必须与实际协议匹配:HTTP 场景用 X-Forwarded-For,四层用 proxy_protocol,部分云厂商使用 X-Real-IP(此时只能取单个地址,无法递归)。三者混用会导致取到空值或上一跳地址。

7. 验证与排错

一句话总结: 验证的核心是构造带伪造头部的请求,观察 remote_addr 是否落在预期值上,并用 debug 日志确认取值路径。

# 从可信网段内发起请求,验证头部被采信
curl -s -H 'X-Forwarded-For: 1.2.3.4, 10.0.0.5' \
     -o /dev/null -w '%{remote_ip}\n' http://127.0.0.1/echo

# 直接访问(非可信来源)时头部应被忽略,remote_addr 保持对端地址
curl -s -H 'X-Forwarded-For: 8.8.8.8' \
     -o /dev/null -w '%{remote_ip}\n' http://127.0.0.1/echo

# 开启 debug 日志观察 real_ip 模块的取值过程
# error_log /var/log/nginx/error.log debug;

更完整的做法是在后端放一个回显服务,直接打印收到的 X-Real-IP 与 X-Forwarded-For,再用不同来源、不同头部组合跑一组用例。下面这段脚本把常见组合一次性覆盖:

#!/usr/bin/env bash
# 逐条验证信任链行为,输出「头部 -> 还原结果」
endpoint="http://127.0.0.1/echo"

cases=(
  "1.2.3.4"
  "1.2.3.4, 10.0.0.5"
  "8.8.8.8, 1.2.3.4, 10.0.0.5, 10.0.1.9"
)

for xff in "${cases[@]}"; do
  ip=$(curl -s -H "X-Forwarded-For: ${xff}" -o /dev/null -w '%{remote_ip}' "$endpoint")
  printf '%-45s -> %s\n' "$xff" "$ip"
done

用 nginx -T 导出完整生效配置,确认 set_real_ip_from 与 real_ip_header 落在预期的 server 块内——这两个指令既可在 http 层也可在 server 层声明,写在错误的层级会导致部分虚拟主机不生效。同时用 nginx -V 2>&1 | grep realip 确认 --with-http_realip_module 已编译进去,缺失时 nginx -t 会直接报 unknown directive。

8. 总结

环节要点
失真原因remote_addr 是 TCP 对端地址,前置代理越多偏差越大
XFF 语义各跳依次追加,最左是客户端,但整段头部可被伪造
信任边界只信任自己掌控的代理,边缘入口优先覆盖而非追加
real_ipset_real_ip_from 声明可信段,real_ip_header 指定取值头部
递归取值real_ip_recursive on 从右向左跳过可信地址取首个不可信值
PROXY protocol四层传递原始地址,两端必须同时开启且仅在可信网络使用
日志限流限流 key 用 binary_remote_addr,日志同时保留原始 XFF
验证构造伪造头部 + debug 日志 + nginx -T 核对生效层级

真实 IP 还原是「安全与可用性互相拉扯」的典型场景:信任网段配得太窄会导致部分链路不生效,配得太宽等于把伪造能力交给攻击者。稳妥的做法是把可信网段清单纳入配置管理、与云厂商文档定期对齐,并在边缘入口坚持覆盖语义。地址还原清楚之后,下一类高频问题出现在长连接场景——WebSocket 与 SSE 的代理需要对超时、心跳和连接数做完全不同的取舍,这是下一篇的主题。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「nginx」更多文章

  1. Nginx 在服务网格中的角色:边车代理、mTLS 与 Envoy 取舍
  2. Nginx 大文件上传与请求体处理:缓冲、临时文件与断点续传
  3. Nginx 证书自动化与 ACME:certbot、DNS-01 通配符与自动续期