自建大模型 API 中转站几乎一定要做按 IP 限流:防注册刷号、防单账号打满上游配额。而这里有一个安静失效的陷阱——如果你的限流按 X-Forwarded-For 计数,那它等于没做。攻击者每个请求塞一个不同的伪造 IP,计数器永远打不满。

根本原因一句话:X-Forwarded-For 是追加语义,X-Real-IP 是覆写语义。 前者保留客户端自带的值,后者丢弃。这个区别决定了哪个可信。

追加与覆写的区别

nginx 里这两行长得很像,行为完全不同:

# 追加:把 $remote_addr 拼在客户端已有的 XFF 后面
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

# 覆写:无条件用 $remote_addr 替换,客户端塞的值被丢掉
proxy_set_header X-Real-IP $remote_addr;

$proxy_add_x_forwarded_for 这个变量的定义就是"客户端请求里的 XFF 值 + , + $remote_addr"。所以攻击者只要在请求里带上 X-Forwarded-For: 1.2.3.4,上游收到的就是:

X-Forwarded-For: 1.2.3.4, 203.0.113.77
                 ↑伪造      ↑真实(nginx 追加的)

而绝大多数框架和教程都教你取 XFF 的第一个值当客户端 IP——理由听起来也很合理:“最左边是最原始的客户端”。这个说法在没有攻击者的世界里成立,在真实互联网上是漏洞。

❌ 按 X-Forwarded-For 限流攻击者自带 XFF: 1.2.3.4追加nginx$proxy_add_x_f_fXFF: 1.2.3.4, 203.0.113.77应用取 [0] → 1.2.3.4限流失效每个请求换一个伪造 IP → 计数器永远打不满✓ 按 X-Real-IP 限流攻击者自带 Real-IP: 1.2.3.4覆写nginx$remote_addrX-Real-IP: 203.0.113.77伪造值已被丢弃限流生效客户端塞什么都没用,值由反代单方面决定关键差异:追加保留了客户端的输入,覆写没有。可信的是覆写。前提:nginx 必须是唯一入口 —— 见下一节
两条路径的唯一区别是 nginx 那一格用了追加还是覆写。「取最左边是最原始客户端」这条常见说法,恰恰是最左边那个值完全由攻击者控制。

前提:反代必须是唯一入口

X-Real-IP 可信有一个隐含前提——所有流量都必须经过反代。如果后端端口自己也在公网上听着,攻击者直连后端,想塞什么头就塞什么头,覆写逻辑根本不会执行。

这个疏漏比想象中普遍。写这篇文章的时候我顺手扫了自己的机器,发现两个问题:一个 Go 服务监听了 0.0.0.0 而不是 127.0.0.1,公网直接可达;更糟的是数据库端口也在安全组里开着,从外网能直接拿到 MariaDB 的协议握手包和版本号。两个都是历史遗留,跑了很久没人注意——因为从正常入口访问一切都是对的,问题只在你主动去扫端口时才会现形。

✓ 后端只监听 127.0.0.1客户端nginx :443覆写头127.0.0.1:8081唯一入口直连尝试 → 连接被拒,没有第二条路❌ 后端监听 0.0.0.0 且安全组开放攻击者绕过 nginx,自带任意 X-Real-IPnginx :4430.0.0.0:8980公网可达全部防护被跳过
下半图里被跳过的不只是 IP 覆写——TLS、限流、鉴权、路径前缀剥离,凡是在 nginx 层做的全部失效。后端监听地址是一道比任何头处理都更靠前的防线。

自查两条命令,五秒钟的事:

# 本机看监听地址:凡是 0.0.0.0 或 * 开头的都要问一句"为什么"
ss -tlnp

# 从外网验证(换成你的公网 IP 和端口),能连上就是有问题
curl -sv -m 5 http://YOUR_PUBLIC_IP:8080/ 2>&1 | head -5

第二条必须从机器外面跑。在机器上自己 curl 自己永远是通的,测不出安全组的实际策略。

修法按优先级:

  1. 应用只监听本地——大多数框架都能配,Go 里就是 http.ListenAndServe("127.0.0.1:8081", h) 而不是 ":8081"
  2. 安全组只开 22 / 80 / 443——这是最后一道闸,也是最不容易被应用改动破坏的一道
  3. 数据库一律不开公网——需要远程访问就走 SSH 隧道:ssh -L 3306:127.0.0.1:3306 user@host

正确的限流写法

nginx 层限流:天然安全

如果限流在 nginx 做,用 $binary_remote_addr 就好——它取的是 TCP 连接的对端地址,不是任何请求头,客户端无法伪造:

# http 块
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

location /v1/ {
    limit_req zone=api burst=20 nodelay;
    limit_req_status 429;
    proxy_pass http://127.0.0.1:3000/v1/;
}

用 $binary_remote_addr 而不是 $remote_addr 是内存考虑:前者是 4 字节(IPv4),后者是字符串,在 10m 的 zone 里能存的条目数差好几倍。

应用层限流:只信覆写过的头

如果限流逻辑在应用里(比如要按用户 + IP 组合计数),那就只认 X-Real-IP:

// ✓ 只信反代覆写的头,且兜底用 TCP 对端地址
func clientIP(r *http.Request) string {
    if ip := r.Header.Get("X-Real-IP"); ip != "" {
        return ip
    }
    host, _, _ := net.SplitHostPort(r.RemoteAddr)
    return host
}

绝对不要写成这样:

// ❌ 取 XFF 第一个值 —— 完全由客户端控制
func clientIP(r *http.Request) string {
    if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
        return strings.Split(xff, ",")[0]  // 攻击者说什么就是什么
    }
    ...
}

我自己的 pet 服务在这一处踩过,最后在 nginx 配置里留了行注释提醒自己:X-Real-IP 不能少,注册限流只认这个头,因为 XFF 客户端能自己塞。 这类结论写在配置文件旁边比写在文档里有用得多——改配置的人一定会看到它。

前面还有 CDN 怎么办

如果套了 CDN,$remote_addr 是 CDN 节点的 IP,直接覆写 X-Real-IP 会把全站流量都算成同几个 CDN 节点,限流立刻误伤所有人。这时候要用 nginx 的 realip 模块,而不是自己解析 XFF:

# 声明哪些来源是可信代理(填 CDN 的回源网段,各家文档都有公布)
set_real_ip_from 203.0.113.0/24;
set_real_ip_from 198.51.100.0/24;

real_ip_header    X-Forwarded-For;
real_ip_recursive on;          # 这一行是安全的关键

配好之后 $remote_addr 会被改写成真实客户端 IP,前面所有基于 $remote_addr 的限流、X-Real-IP 覆写都自动正确,不用改一行业务代码。

real_ip_recursive on 为什么是安全的关键,值得画一下:

X-Forwarded-For 的实际内容1.2.3.4攻击者伪造,203.0.113.77真实客户端,203.0.113.9CDN 节点(可信)从右开始扫① 在 set_real_ip_from 里 → 跳过② 不在可信列表里 → 取它,扫描停止 ✓③ 永远扫不到这里扫描一旦遇到非可信地址就停止,所以左侧的伪造值不可能被取到。安全性完全依赖 set_real_ip_from 填得准 —— 填宽了等于自己把门打开。
方向就是安全性本身。从右往左扫、遇到非可信地址即停,意味着攻击者在左边塞多少个伪造 IP 都到不了。反过来从左往右取第一个,就是本文开头那个漏洞。

两个配置陷阱:

  • real_ip_recursive off(默认值)在有 CDN 时是错的。 关闭递归时 nginx 只取 XFF 的最后一个地址,那就是 CDN 节点自己,等于没解析。
  • set_real_ip_from 千万别填 0.0.0.0/0。 把所有来源都标为可信,递归就会一路扫到最左边——正好扫到攻击者伪造的那个值,比不配还糟。见过有人为了"省事"这么写。

一份自查清单

检查项命令 / 位置期望
后端监听地址ss -tlnp只有反代和 SSH 在 0.0.0.0,业务端口都在 127.0.0.1
外网可达端口从外部机器 curl 各端口只有 22 / 80 / 443 通
数据库端口ss -tlnp | grep -E '3306|5432'绑 127.0.0.1,且安全组不开
应用取 IP 的逻辑搜代码里的 X-Forwarded-For不存在取 [0] 的写法
nginx 是否覆写搜配置 X-Real-IP每个 proxy_pass 的 location 都有
CDN 场景搜配置 real_ip_recursive为 on,且 set_real_ip_from 是具体网段

相关阅读