3.10 那篇(注册安全加固:同 IP 注册频控 + 反代真实客户端 IP 解析)里,我为了拿到反代后的客户端 IP,写了一段"取 X-Forwarded-For 首段"的代码,并留下一句结论:最左首段才是最原始客户端

这篇是勘误

那句话不是"不够严谨",是错的——而且错得挺危险:它等于把同 IP 注册频控的 key,直接交给了调用方决定。攻击者不需要任何工具,一行请求头就能让频控失效。

更值得记的是,我当初为什么会这么写——不是没查,而是查错了地方

配套代码:src/controllers/UserController.ccclientIp()(改用 drogon::plugin::RealIpResolver::GetRealAddr)、config.jsonplugins 段(trust_ips / from_header)、src/service/RegisterGuard.{h,cc}(被绕过的同 IP 频控);验证脚本 scripts/verify-311-real-ip.sh(静态检查 + 编译)、scripts/e2e-311-real-ip.sh(两相端到端,含伪造 XFF 复现)。仓库:https://github.com/Hespethorn/seckill-cpp。相关上游 issue:RealIpResolver 的 IPv6 缺口(drogonframework/drogon#2596)。

一、是什么、坑在哪、本质一句话

是什么X-Forwarded-For(XFF)是代理逐跳追加的客户端地址链,形如 A, B, C。本节要回答一个问题:这条链里,哪一段才是真的客户端地址?

坑在哪

  • 首段不是"最原始客户端",是"客户端自称的地址"。它由发起请求的人决定,服务端在解析前没有任何办法验证。
  • Nginx 最常见的配置恰好把伪造值放大proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;追加而不是覆盖。客户端自带一个 XFF 时,结果是 伪造值, 真实客户端IP——真实地址被挤到了右边。
  • 直连场景更彻底:没有代理追加,XFF 里从头到尾都是客户端自己写的,服务端照单全收。
  • 误判"框架不提供该能力":我写 req->getClientIp() 编译报错,翻 HttpRequest.h 也确实找不到,于是断定 Drogon 不内建从 XFF 解析真实 IP 的能力,只能自己写。这个判断是错的——答案不是 HttpRequest 上的方法,而是独立插件 drogon::plugin::RealIpResolver

本质一句话X-Forwarded-For 不是"客户端地址列表",而是一串自称——只有从可信代理那一侧往左数,第一个不可信的地址才是可信的。

二、XFF 到底是谁写的

要理解坑在哪,先得看清这条链是怎么长出来的。

XFF 的语义是"每一跳把自己的上游地址记下来"。真正可信的只有一件事:离你最近的那个 TCP 对端getPeerAddr() 的结果),因为那是内核告诉你的、伪造不了的连接来源。至于连接上带的那串 XFF——在验证之前,它全是字符串

同样一段 XFF,两种完全不同的来历 正常链路 客户端(真实 IP 1.2.3.4)不带 XFF 发动请求 Nginx 追加自己的上游地址 → 请求头变成: X-Forwarded-For: 1.2.3.4 ← 首段确实是真的 伪造链路(攻击者) 客户端主动带上伪造 XFF 再发请求 Nginx 照旧追加真实地址 → 请求头变成: X-Forwarded-For: 9.9.9.9, 1.2.3.4 ← 首段是伪造的

两条链路的请求头涨得一模一样——服务端光看 XFF 值,根本分不出哪条是真的。这就是"取首段"最致命的盲点:它假设了客户端一定是诚实的

那么真实地址在哪?在伪造链路里,1.2.3.4 是 Nginx 亲眼看到的 TCP 对端地址,攻击者伪造不了;在正常链路里,它就是首段。唯一的区别是位置——而位置取决于"链上哪一跳开始不可信"。

三、攻击复现:一行 header 绕掉同 IP 注册频控

3.10 的频控设计是这样的:key 取 reg:ip:<ip>,窗口 1 小时内最多成功注册 5 次,超限返回 429。防线成立的前提是——<ip> 得是攻击者改不动的东西

而当时的 clientIp() 取的是 XFF 首段。于是:

1
2
3
4
5
6
7
# 每次换一个 X-Forwarded-For,频控计数永远落在新 key 上
for i in $(seq 1 100); do
curl -s -X POST http://127.0.0.1:8080/api/user/register \
-H "Content-Type: application/json" \
-H "X-Forwarded-For: 10.1.1.$i" \
-d "{\"phone\":\"1380000$(printf %04d $i)\",\"password\":\"pass1234\",\"code\":\"<验证码>\"}"
done

每轮请求的 key 分别是 reg:ip:10.1.1.1reg:ip:10.1.1.2……每个 key 都只被用了一次max_per_ip=5 的上限永远碰不到。防线形同虚设。

说明两点,避免误导:
① 本文只针对 IP 频控这一道防线。验证码链路另有自己的限流(按发送节奏与每日总量),那条不受本问题影响——但它防的是"拿码成本",不是"同 IP 建号数量"。
② 走上 Nginx 也救不了:$proxy_add_x_forwarded_for 产生的 10.1.1.x, 真实IP 里,首段依旧是伪造值

这里最反直觉的一点是:这段代码在直连压测下永远是"正常"的。因为直连时没有 XFF,clientIp() 走的是回退分支 getPeerAddr()——拿到的就是真实地址,频控工作得好好的。我当时的 WSL + JMeter 压测基线全绿,一次都没暴露这个问题。它只在"真实部署 + 有人故意伪造"的组合下才会现形。

四、正确做法:先验代理,再从右往左

正确解析要两步,缺一不可。

第一步:确认直连方是可信代理。 如果 TCP 对端本身就不在可信代理名单里,那么 XFF 里的任何内容都不该采信——直接用 getPeerAddr()。这一步挡掉的是"直连伪造"。

第二步:从右往左遍历,跳过可信代理,取第一个不可信 IP。 因为右边那几段才是代理亲眼所见、攻击者够不着的。这一步挡掉的是"反代链路上的伪造"。

从右往左找:第一个「不可信」的地址就是真客户端 X-Forwarded-For: 9.9.9.9(伪造), 172.16.0.5(内网代理1), 10.0.0.1(内网代理2) 第 1 步:右起 10.0.0.1 命中 trust_ips → 跳过 第 2 步:右起 172.16.0.5 命中 trust_ips → 跳过 9.9.9.9 不可信 → 不采信 若整条链都是可信代理,或链走完仍无不可信地址 → 回退到 getPeerAddr()(TCP 对端永远伪造不了)

这套逻辑不用自己写——Drogon 官方插件 RealIpResolver 就是干这个的,v1.9.10 起内置。它的核心实现(lib/src/RealIpResolver.cc):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 第一步:直连方不是可信代理 → 压根不看 XFF
const trantor::InetAddress &peerAddr = req->getPeerAddr();
if (ipHeaderFind == headers.end() || !matchCidr(peerAddr, trustCIDRs_)) {
req->attributes()->insert(attributeKey_, peerAddr);
return;
}

// 第二步:从右往左遍历,跳过可信代理,取第一个不可信 IP
XForwardedForParser parser(ipHeader); // 从字符串末尾往前解析
std::string ip;
while (!(ip = parser.getNext()).empty()) {
trantor::InetAddress addr = parseAddress(ip);
if (addr.isUnspecified() || matchCidr(addr, trustCIDRs_)) continue;
req->attributes()->insert(attributeKey_, addr);
return;
}
// 全链都是可信代理 → 回退 TCP 对端
req->attributes()->insert(attributeKey_, peerAddr);

于是在 config.json 里启用它:

1
2
3
4
5
6
7
8
{
"name": "drogon::plugin::RealIpResolver",
"config": {
"trust_ips": [],
"from_header": "x-forwarded-for",
"attribute_key": "real-ip"
}
}

这里 trust_ips: []直连部署(前面没有反向代理)的正确值——列表为空则 matchCidr 永远不成立,X-Forwarded-For 一律被忽略,谁也没法伪造。将来真挂上 nginx,再把它的地址填进去。

业务侧那 20 行手写解析整体删掉,缩成一行:

1
2
3
4
5
6
// src/controllers/UserController.cc
// 插件已完成「验可信代理 + 从右往左取首个不可信 IP」;
// 插件未注册时 GetRealAddr 内部回退 getPeerAddr(),属安全降级方向。
std::string clientIp(const drogon::HttpRequestPtr &req) {
return drogon::plugin::RealIpResolver::GetRealAddr(req).toIp();
}

顺带发现,已提上游:这个插件在 IPv6 下还有两个更隐蔽的缺口。
trust_ips 里填 IPv6 地址,拿到的是 Bad ipv4 address: 2001:db8::1——把"合法但不受支持"说成了"地址格式错误"(那句 Ipv6 is not supported by RealIpResolver. 因为 trantor 的字符串构造不识别协议族,实际不可达)。
X-Forwarded-For 里的 IPv6 条目会被静默丢弃parseAddressfind(':') 区分 host/port,与 IPv6 语法冲突,解析出的地址 isUnspecified() 为真,在遍历循环里被 continue 跳过,最终回退成 TCP 对端——也就是代理的地址。
后果和「trust_ips 填窄」同形状:反代后所有 IPv6 客户端收缩成同一个频控 key,而且日志里一切正常。dual-stack 部署下这不是边角场景。
自 2022-07 插件合并(PR #1321)至今一直如此。已提 issue:drogonframework/drogon#2596
(内容来自读上游源码推导,未跑复现——issue 里也是这么标注的。)

五、"编译过了"不算数:两相端到端实测

修完这段代码,我先跑静态检查 + 编译,全过。但编译只能证明它编得过——那时候我还不知道 reg:ip 的 key 到底会不会写着 127.0.0.1,因为我从没在"有人伪造 XFF"的情况下真的跑过一次注册。

于是写了个脚本(scripts/e2e-311-real-ip.sh):起真服务、发真请求、看真 Redis。不过在它跑通之前,我手敲过一版 curl 循环——结论是什么都没验证到。踩了两个坑,都比修复本身值得记。

坑一:频控 key 只在"注册成功"的那一刻才存在

三个响应全是 {"code":1,"msg":"CODE_EXPIRED"},Redis 里一条 reg:ip:* 都没有。回去读 RegisterGuard 才明白:

1
2
3
4
5
6
7
8
9
10
// kCheckScript:纯读比对,不修改计数
"local n = redis.call('GET', KEYS[1]) "
"if not n then return 1 end "
"if tonumber(n) < tonumber(ARGV[1]) then return 1 end "
"return 0"

// kMarkScript:真正的 INCR 发生在注册成功之后
"local n = redis.call('INCR', KEYS[1]) "
"if n == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end "
"return n"

check() 只读不写,markSuccess() 才写——而它挂在 INSERT INTO user 的成功回调里。所以验证码开着,请求就止步于 CODE_EXPIREDkey 压根不会被创建。扫出空集看着"没问题",实际零信息量。

要验证频控,得先让注册真的成功:把 auth.require_sms_on_register 关掉。

坑二:本机 curl 的对端,恰好可能就是"可信代理"

这条是我自己的配置埋的雷:trust_ips 里写着 ["127.0.0.1"],而我的 curl 就是从 127.0.0.1 发出的——插件把我当成了可信代理,于是采信了我伪造的 XFF。(验证跑通后,我把它改成了 [],理由见上面「正确做法」那段。)

第一版脚本只跑一次、期望"key 恒为 reg:ip:127.0.0.1",照那个跑会直接误报"修复没生效"。所以脚本改成跑两相,把这个契约摆到台面上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
==================================================================
A 相:trust_ips = [](无代理 / 直连部署的正确配置)
==================================================================
[1] XFF=10.1.1.1 phone=13979614925
-> {"code":0,"data":null,"msg":"success"}
[2] XFF=10.1.1.2 phone=13979614926
-> {"code":0,"data":null,"msg":"success"}
[3] XFF=10.1.1.3 phone=13979614927
-> {"code":0,"data":null,"msg":"success"}
Redis 中的 reg:ip:* :
reg:ip:127.0.0.1 → 计数 3
[PASS] 唯一的 key 就是 TCP 对端 127.0.0.1(伪造的 XFF 被彻底忽略)
[PASS] 计数 = 3:3 次注册全部落在同一个 key 上
[PASS] 没有任何 10.1.1.x 的 key —— 伪造 XFF 完全没起作用

==================================================================
B 相:trust_ips = ["127.0.0.1"](声明本机为可信代理)
==================================================================
[1] XFF=10.1.1.1 phone=13779614927
-> {"code":0,"data":null,"msg":"success"}
[2] XFF=10.1.1.2 phone=13779614928
-> {"code":0,"data":null,"msg":"success"}
[3] XFF=10.1.1.3 phone=13779614929
-> {"code":0,"data":null,"msg":"success"}
Redis 中的 reg:ip:* :
reg:ip:10.1.1.1 → 计数 1
reg:ip:10.1.1.2 → 计数 1
reg:ip:10.1.1.3 → 计数 1
[PASS] 出现 3 个 key —— 插件采信了「可信代理」发来的 XFF,这正是 trust_ips 的契约
[PASS] 三个 key 恰好是 reg:ip:10.1.1.{1,2,3},与伪造值一一对应

两相都 PASS,因为它们说的是两个不同命题:

trust_ips 伪造的 XFF 结论
A 相 [] 被忽略,key 恒为 reg:ip:127.0.0.1,计数 3 直连部署下,伪造没有入口
B 相 ["127.0.0.1"] 被采信,key 变成 reg:ip:10.1.1.{1,2,3},各记 1 信任列表里的来源,其 XFF 说了算

B 相看着像"洞还在",其实不是——它只是把 trust_ips 的契约演示了一遍:你声明谁是代理,就等于让那个来源决定频控 key。这套配置本身没错,错的是把它用在"根本没有反代"的场景里;那种场景该填 [](也就是 A 相)。

为什么值得写脚本,而不是手敲几条 curl

因为上面两个坑手敲必踩:第一个让测试静默失效(看着绿,其实没测到),第二个让结论直接反掉(把配置契约当成了漏洞)。而且验证本身需要状态管理——起服务、关验证码、清 key、保证手机号唯一、收尾还原配置:

1
2
3
4
==> 收尾
已清理 reg:ip:* :reg:ip:10.1.1.1 reg:ip:10.1.1.3 reg:ip:10.1.1.2
服务已停止
config.json 已还原(与备份逐字节一致)

本质一句话:验证一个安全修复,不是"跑一遍看它没报错",而是设计一个它本该失败的反例,然后看它有没有真的失败

六、对比表

方案 直连伪造 XFF 反代后的真实 IP 多级代理链 代码量
getPeerAddr() 直接用 安全(伪造无效) ✗ 拿到代理 IP,频控全员共享一个 key ✗ 同上 1 行
手取 XFF 首段(原实现) 完全绕过 ✗ 拿到伪造值($proxy_add_x_forwarded_for 追加语义) ✗ 拿到伪造值 约 20 行
手取 XFF 末段 安全 ✗ 多级代理时拿到的是倒数第二跳代理 ✗ 同上 数行
RealIpResolver(现实现) 安全(先验 peer) ✓ 正确 ✓ 从右往左逐个跳过 1 行 + 配置

只有最后一行同时满足"防伪造"和"反代后正确"。

功能抉择(本篇核心权衡)

① 为什么直接用官方插件,而不是把自己写的那版改对?

手改一版其实不难——核心就是"验 peer + 从右往左"两段逻辑。但我选插件,理由是安全代码的维护成本不对称

  • 手写版要自己实现 CIDR 匹配trust_ips 通常是一整个网段,不是单个 IP)。我最初那版只做了单 IP 相等比较,看着能用,实际部署里网段一改就漏。
  • 手写版还要处理边界:XFF 里混入非法 IP、端口后缀(1.2.3.4:5678)、多余空格、超长链。官方的 XForwardedForParser + parseAddress 已经把这些都覆盖了。
  • 最关键的是:这属于"我错了会挨打"的代码。放在上游,有更多人 review、更多场景压过;放在自己项目里,只有我一个人的盲区。

顺带一提,我也确认过它的降级行为是安全的:插件未注册、或 pre-routing 阶段没跑到时,GetRealAddr 内部会回退到 getPeerAddr()——最坏情况退化成"不用 XFF",而不是"乱用 XFF"。这个方向对了。

② 为什么 trust_ips 必须按真实部署精确配置?

填错的后果常被误解,而且不止一个方向。要理解它,得先接受一件事:trust_ips 的语义不是"白名单",而是**"我信任谁的 X-Forwarded-For"**——你声明谁是代理,就等于把"由谁来决定频控 key"交了出去。上游实现把这件事写得很直白:

1
2
3
4
5
6
7
if (ipHeaderFind == headers.end() || !matchCidr(peerAddr, trustCIDRs_)) {
// 对端不在 trust_ips 里 → 完全不看 XFF,直接用 TCP 对端地址
req->attributes()->insert(attributeKey_, peerAddr);
return;
}
// 对端可信 → 从右往左扫 XFF,跳过不可解析的与可信的,取第一个不可信 IP
XForwardedForParser parser(ipHeader);

所以填错有两个方向,代价完全不同:

  • 填窄了(对端不在列表内):走上面第一个分支,插件忽略 XFF、退回 TCP 对端地址。伪造依然无效——这一侧确实是安全的。代价全在可用性:反代与 Drogon 同机时 127.0.0.1 是对的;一旦反代跑在 Docker 里(比如 172.17.0.2),而 trust_ips 还留着 127.0.0.1,那么所有请求都会退化成同一个 Docker 网关 IP——同 IP 频控从"防刷号"变成了"一小时内全站只能注册 5 次"。这是典型的安全策略配置错误导致的全量误伤,比漏洞本身更难排查,因为日志里一切正常。
  • 填宽了(把客户端也能连到的地址、甚至 0.0.0.0/0 放进列表):对端命中信任列表,插件就会采信这个来源的 XFF、从右往左取第一个不可信 IP 当客户端地址。伪造当场生效——花半小时修好的洞,一行配置就还回去了。

我最初只想到第一个方向,甚至写过"填错不会放大安全风险"这种话。那句话只在"填窄"时成立,我把一半结论当成了全部。

至于"直连、前面根本没有反代"该怎么填:trust_ips: []——本项目当前就是这个值。列表为空 → matchCidr 永远不成立 → XFF 一律忽略,彻底没有伪造空间。一个不存在的代理,不该出现在信任列表里。

③ 既然不能取首段,为什么还要让 Nginx 配 XFF?

因为**"服务端怎么解析"和"链路怎么记录"是两件事**。XFF 仍然是链路可回溯性的唯一来源——排查问题时你需要看到完整的代理链;只是这条链必须从可信的一端开始读,而不是从不可信的一端。

小结

  • X-Forwarded-For逐跳自称的地址链,首段由调用方控制、可任意伪造;取首段做频控 key,等于把 key 交给攻击者。
  • Nginx 的 $proxy_add_x_forwarded_for追加语义,会把伪造值留在最左——所以"配了反代"并不能让首段变可信。
  • 正确解析需两步:① 先验直连方是否可信代理(不是就完全不信 XFF);② 从右往左跳过可信代理,取第一个不可信 IP
  • Drogon 官方 RealIpResolver 插件(v1.9.10 内置)已实现该逻辑;trust_ips 的语义是**「我信任谁的 XFF」**——填窄了只是频控全量误伤(可用性问题),填宽了伪造会当场生效(安全问题);直连无前置反代时填 [](本项目即如此)。
  • 顺带记一个上游缺口:这个插件只支持 IPv4——trust_ips 里填 IPv6 会拿到误导性的 Bad ipv4 address,而 XFF 里的 IPv6 条目会被静默丢弃、回退成代理地址;反代后 IPv6 客户端会收缩成同一个 key,与"填窄 trust_ips"同形状。已提 drogonframework/drogon#2596
  • "能编过"不等于"验证过"reg:ip:<ip> 只在注册成功那一刻才写入,所以验证脚本必须让注册真的成功(临时关掉注册验证码),并且跑两相——A 相证明不可信来源的伪造无效,B 相把 trust_ips 的契约边界摆出来。只跑单向、或扫出空集,照样是"绿的"却什么都没测到。
  • 最后一个教训跟框架无关:这段错误代码在直连压测下永远是绿的。压测能验证性能,验证不了"有人故意不按规矩发请求"——安全假设必须单独想一遍;而且**"想一遍"不够,得设计一个它本该失败的反例,真的跑一遍**。🐾

配套源码

本文对应代码已开源:Hespethorn/seckill-cpp

C++ / Drogon 高并发秒杀系统实战,含完整的压测基线、技术决策记录(ADR)与可复现脚本:

  • 阶段一(直打数据库):QPS≈440,0 超卖
  • 阶段二(Redis 缓存):读接口 list 10557 QPS / detail 8024 QPS

如果对你有帮助,欢迎 star ⭐