自建大模型 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-Real-IP 可信有一个隐含前提——所有流量都必须经过反代。如果后端端口自己也在公网上听着,攻击者直连后端,想塞什么头就塞什么头,覆写逻辑根本不会执行。
这个疏漏比想象中普遍。写这篇文章的时候我顺手扫了自己的机器,发现两个问题:一个 Go 服务监听了 0.0.0.0 而不是 127.0.0.1,公网直接可达;更糟的是数据库端口也在安全组里开着,从外网能直接拿到 MariaDB 的协议握手包和版本号。两个都是历史遗留,跑了很久没人注意——因为从正常入口访问一切都是对的,问题只在你主动去扫端口时才会现形。
自查两条命令,五秒钟的事:
# 本机看监听地址:凡是 0.0.0.0 或 * 开头的都要问一句"为什么"
ss -tlnp
# 从外网验证(换成你的公网 IP 和端口),能连上就是有问题
curl -sv -m 5 http://YOUR_PUBLIC_IP:8080/ 2>&1 | head -5
第二条必须从机器外面跑。在机器上自己 curl 自己永远是通的,测不出安全组的实际策略。
修法按优先级:
- 应用只监听本地——大多数框架都能配,Go 里就是
http.ListenAndServe("127.0.0.1:8081", h)而不是":8081" - 安全组只开 22 / 80 / 443——这是最后一道闸,也是最不容易被应用改动破坏的一道
- 数据库一律不开公网——需要远程访问就走 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 为什么是安全的关键,值得画一下:
两个配置陷阱:
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 是具体网段 |
相关阅读
- 反代把大模型 API 的 SSE 流式响应缓冲了:五个缓冲点与三层排查法 —— 同一份 nginx 配置里的另一个坑
- new-api 部署选型:2G 小内存机器上裸二进制比 Docker 省多少 —— 后端监听
127.0.0.1在两种部署形态下怎么配