取 X-Forwarded-For 首段做频控 key,等于把限流开关交给了调用方
最近我在一个 C++ 秒杀项目里写了一段"取 X-Forwarded-For 首段"的代码,用来在反向代理后面拿到真实客户端 IP。这段代码编译通过、压测全绿、curl 手测正常。
它是错的。而且错得很危险:攻击者不需要任何工具,一行请求头就能让同 IP 注册频控彻底失效。
这篇把整个过程拆开讲------包括我为什么会写错(不是没查,是查错了地方)、正确做法在源码层面长什么样、为什么"能编过"根本不等于"验证过",以及顺着这条线往下挖时发现的一个上游框架缺口。
0. 结论先放最前面
如果你只想记四句话:
X-Forwarded-For是"逐跳自称"的地址链,首段由发起方控制,可任意伪造。 拿它当频控 key,等于把 key 交给攻击者。- Nginx 最常用的
$proxy_add_x_forwarded_for是追加语义,会把伪造值留在最左边------所以"我配了反代"并不能让首段变可信,反而可能放大伪造。 - 正确解析要两步:先验证直连方是不是可信代理(不是就完全不信 XFF),再从右往左跳过可信代理、取第一个不可信地址。 少任一步都不成立。
- 这段代码在直连压测下永远是绿的------因为直连时没有 XFF,代码走的是回退分支,拿到的就是真实地址。这个问题只在"真实部署 + 有人故意伪造"的组合下才会现形。
1. 起点:一段当时看起来完全正确的代码
场景是个 C++ 秒杀系统(Drogon 框架)。我在 3.10 那篇里加了"同 IP 注册频控"------key 取 reg:ip:<ip>,1 小时内最多成功注册 5 次,超限返回 429。
频控本身没问题,问题在 <ip> 从哪来。生产上服务在 Nginx 后面,getPeerAddr() 拿到的是 Nginx 的地址,所有用户会挤成一个 key,那频控就成了"全站一小时 5 次"。所以必须从 X-Forwarded-For 里取真实客户端 IP。
我当时写的是这个:
cpp
// 已废弃的错误实现(简化)
std::string clientIp(const drogon::HttpRequestPtr &req) {
if (auto xff = req->getHeader("x-forwarded-for"); !xff.empty()) {
auto pos = xff.find(',');
// 取第一个逗号之前的内容 = "最左首段"
return (pos == std::string::npos) ? xff : xff.substr(0, pos);
}
return req->getPeerAddr().toIp(); // 回退
}
这段代码的推理链条是这样的:
XFF 是"客户端 → 各级代理"的路径记录,那么最左边的应该就是最早的、也就是最原始的客户端。
听起来很顺。问题是它有一个隐含前提:链路上每个人都在说实话。
而且我当时还留了一句话写在博客里------"最左首段才是最原始客户端"。现在回头看,那不是"不够严谨",是把一个错误假设写成了结论。
我为什么会查错地方
这一点比 bug 本身更值得记。
我在业务代码里写 req->getClientIp(),编译报错。翻 HttpRequest.h,确实找不到这个方法。于是在当时的心智模型里得出了结论:Drogon 不内建从 XFF 解析真实 IP 的能力,只能自己写。
这个判断是错的。能力在,只不过不在 HttpRequest 上,而是独立插件 drogon::plugin::RealIpResolver(v1.9.10 起内置)。
教训:在框架 API 里搜不到某个能力时,"它不存在"和"它在别的地方"是两个完全不同的结论,而后者通常更接近事实。方法论上的正确动作是搜文档/仓库,而不是搜头文件------我跳过了这一步,因为"翻头文件没找到"给人的确认感太强了。
2. X-Forwarded-For 到底是谁写的
要理解坑在哪,先得看清这条链是怎么长出来的。
XFF 的语义是"每一跳把自己的上游地址记下来 "。真正可信的只有一件事:离你最近的那个 TCP 对端 ------那是内核告诉你的、伪造不了的连接来源。至于连接上带的那串 XFF,在验证之前它全是字符串。
markdown
【正常链路】
客户端(真实 1.2.3.4) ──▶ Nginx ──▶ 你的服务
(不带 XFF) X-Forwarded-For: 1.2.3.4
↑ 首段确实是真的
【伪造链路】
攻击者 ──▶ Nginx ──▶ 你的服务
(XFF 里自带 9.9.9.9) 追加真实地址 X-Forwarded-For: 9.9.9.9, 1.2.3.4
↑ 首段是伪造的
真实地址被挤到右边
注意关键点:两条链路的请求头"长得一模一样"------都是"一个地址"或"地址, 地址"的形式。服务端光看 XFF 的值,分不出哪条是真的。
这就是"取首段"最致命的盲点:它假设了客户端一定是诚实的。而现实里,攻击者就是客户端。
真实地址在哪里?在伪造链路里,1.2.3.4 是 Nginx 亲眼看到的 TCP 对端,攻击者伪造不了;在正常链路里,它就是首段。唯一的区别是它在链上的位置------而位置取决于"从哪一跳开始不可信"。
3. 复现:一行 header 绕掉同 IP 注册频控
因为 clientIp() 取的是首段,而首段完全由请求方控制,于是:
bash
# 每次换一个 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.1、reg: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 压测基线全绿,一次都没暴露这个问题。
压测能验证性能,验证不了"有人故意不按规矩发请求"。 安全假设必须单独想一遍------而这段代码在压测里给出的绿灯,反而给了我一种"这块已经测过了"的错觉。
4. 正确解析:两步,缺一不可
第一步:确认直连方是可信代理。 如果 TCP 对端本身不在可信代理名单里,那么 XFF 里的任何内容都不该采信------直接用 getPeerAddr()。这一步挡的是"直连伪造"。
第二步:从右往左遍历,跳过可信代理,取第一个不可信 IP。 右边那几段才是代理亲眼所见、攻击者够不着的。这一步挡的是"反代链路上的伪造"。
css
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,跳过
第 3 个:9.9.9.9 → 不可信 → 这就是"真实客户端"
如果整条链都是可信代理,或者链走完仍没有不可信地址
→ 回退到 getPeerAddr()(TCP 对端永远伪造不了)
这套逻辑不用自己写。Drogon 官方插件 RealIpResolver 就是干这个的,核心实现(lib/src/RealIpResolver.cc):
cpp
// 第一步:直连方不是可信代理 → 压根不看 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):
json
{
"name": "drogon::plugin::RealIpResolver",
"config": {
"trust_ips": [],
"from_header": "x-forwarded-for",
"attribute_key": "real-ip"
}
}
业务侧那 20 行手写解析整体删掉,缩成一行:
cpp
// src/controllers/UserController.cc
// 插件已完成「验可信代理 + 从右往左取首个不可信 IP」;
// 插件未注册时 GetRealAddr 内部回退 getPeerAddr(),属安全降级方向。
std::string clientIp(const drogon::HttpRequestPtr &req) {
return drogon::plugin::RealIpResolver::GetRealAddr(req).toIp();
}
顺带确认过它的降级行为是安全的:插件未注册、或 pre-routing 阶段没跑到时,GetRealAddr 内部会回退到 getPeerAddr()------最坏情况退化成"不用 XFF",而不是"乱用 XFF"。方向对了。
5. trust_ips 不是白名单,是"我信任谁的 XFF"
上面配置里 trust_ips: [] 这个空数组,是我纠正过一次认知之后才敢写的。
trust_ips 常被当成"IP 白名单"理解,但它真正的语义是:我信任谁的 X-Forwarded-For。你声明谁是代理,就等于把"由谁来决定频控 key"交了出去。
填错有两个方向 ,而代价完全不同------我最初只想到一个,甚至写过"填错不会放大安全风险"这种话。那句话只在"填窄"时成立,我把一半结论当成了全部。
| 填法 | 对端是否命中 | 插件行为 | 后果 | 性质 |
|---|---|---|---|---|
| 填窄(对端不在列表) | 否 | 忽略 XFF,退回 TCP 对端地址 | 伪造依然无效;但反代与业务不同机时,所有请求退化成同一个网关 IP → 同 IP 频控变成"全站一小时 5 次" | 可用性问题 |
| 填宽(把客户端也能连到的地址放进列表) | 是 | 采信该来源的 XFF,从右往左取首个不可信 IP | 伪造当场生效------花半小时修好的洞,一行配置就还回去了 | 安全问题 |
| 直连(前面没有反代) | --- | --- | 应填 []:列表为空则 matchCidr 永远不成立,XFF 一律忽略 |
正确配置 |
"填窄"那个方向的隐蔽性值得单独说:它是安全策略配置错误导致的全量误伤 ,比漏洞本身更难排查,因为日志里一切正常------服务活着、请求 200、只是频控在按一个共享 key 计数。
所以一个不存在的代理,不该出现在信任列表里。本项目当前(没有前置反代)就是 []。
6. 验证:为什么"编译过了"不算数
修完代码,我跑了静态检查 + 编译,全过。
但那时候我还不知道 reg:ip 的 key 到底会不会写成 127.0.0.1------因为我从没在"有人伪造 XFF"的情况下真的跑过一次注册。
下面是这段验证里踩的两个坑,比修复本身更值得记。
坑一:频控 key 只在"注册成功"那一刻才存在
我手敲了一版 curl 循环,三个响应全是 {"code":1,"msg":"CODE_EXPIRED"},Redis 里一条 reg:ip:* 都没有。
回去读 RegisterGuard 才明白:
cpp
// 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_EXPIRED,key 压根不会被创建。
扫出一个空集,看着"没问题",实际零信息量。这是验证脚本最容易骗到自己的方式:断言在"什么都没有"的前提下也成立。
要让验证有意义,必须让注册真的成功------临时把 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",照那个跑会直接误报"修复没生效"。
所以脚本改成跑两相 ,把 trust_ips 的契约摆到台面上:
ini
==================================================================
A 相:trust_ips = [](无代理 / 直连部署的正确配置)
==================================================================
[1] XFF=10.1.1.1 phone=13979614925 -> {"code":0,...,"msg":"success"}
[2] XFF=10.1.1.2 phone=13979614926 -> {"code":0,...,"msg":"success"}
[3] XFF=10.1.1.3 phone=13979614927 -> {"code":0,...,"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,...,"msg":"success"}
[2] XFF=10.1.1.2 phone=13779614928 -> {"code":0,...,"msg":"success"}
[3] XFF=10.1.1.3 phone=13779614929 -> {"code":0,...,"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},与伪造值一一对应
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} |
信任列表里的来源,其 XFF 说了算 |
B 相看着像"洞还在",其实不是------它只是把 trust_ips 的契约演示了一遍。这套配置本身没错,错的是把它用在"根本没有反代"的场景里。
本质一句话 :验证一个安全修复,不是"跑一遍看它没报错",而是设计一个它本该失败的反例,然后看它有没有真的失败。
7. 顺带发现:这个官方插件在 IPv6 下还有两个更隐蔽的缺口
上面那些都是自己的问题。但既然读了源码,顺手往下看了一层,发现这个插件只支持 IPv4------而且头文件里官方自己就承认了:
cpp
/**
* @note This plugin currently supports only ipv4 address or cidr.
*/
双栈部署(现在很常见)下,会撞上两个都不报错的失败。
缺口①:trust_ips 里填 IPv6,拿到的是误导性报错
CIDR 构造函数里其实专门为 IPv6 准备了一句异常:
cpp
trantor::InetAddress addr(ipv4, 0); // 第三个参数 ipv6 默认为 false
if (addr.isIpV6())
{
throw std::runtime_error("Ipv6 is not supported by RealIpResolver.");
}
if (addr.isUnspecified())
{
throw std::runtime_error("Bad ipv4 address: " + ipv4);
}
看起来很周全。但关键在于:trantor 的这个字符串构造函数并不识别协议族 ,isIpV6_ 直接取自第三个参数:
cpp
InetAddress::InetAddress(const std::string &ip, uint16_t port, bool ipv6)
: isIpV6_(ipv6) // ← 不看字符串内容,直接用传进来的值
{
...
if (::inet_pton(AF_INET, ip.c_str(), &addr_.sin_addr) <= 0)
{
return; // 提前返回,isUnspecified_ 保持 true
}
isUnspecified_ = false;
}
默认 ipv6 = false 时喂进 2001:db8::1:inet_pton(AF_INET, ...) 失败、提前 return,isUnspecified() 为真,而 isIpV6() 恒为 false。
于是结果是:那句 IPv6 专属的 throw 实际不可达,用户看到的是------
yaml
Bad ipv4 address: 2001:db8::1
一个合法但不受支持的 IPv6 地址,被说成了"地址格式错误"。这会把人往完全错误的方向带(去检查配置格式,而不是去查插件支持范围)。
更坑的一点:trantor 的头文件把这个构造标注为 @param ip A IPv4 or IPv6 address.------文档在提示它会自动识别,实现却没有。 这个不一致让"误以为自动识别"变得非常自然。
缺口②:X-Forwarded-For 里的 IPv6 条目会被静默丢弃
这个后果更严重。parseAddress() 用 find(':') 来切 host 和 port:
cpp
static trantor::InetAddress parseAddress(const std::string &addr)
{
auto pos = addr.find(':'); // ← IPv6 地址本身就含冒号
uint16_t port = 0;
if (pos == std::string::npos) return trantor::InetAddress(addr, 0);
try { port = std::stoi(addr.substr(pos + 1)); }
catch (const std::exception &ex) {
LOG_ERROR << "Error in ipv4 address: " + addr; // 报错文案也写死了 ipv4
port = 0;
}
return trantor::InetAddress(addr.substr(0, pos), port);
}
喂进 2001:db8::1:find(':') 命中第一个 冒号(位置 4)→ stoi("db8::1") 抛异常(被 catch,port 置 0)→ substr(0, 4) 得到 "2001" → 构造出 InetAddress("2001", 0)。
而 inet_pton(AF_INET, "2001") 同样失败,所以这个地址的 isUnspecified() 仍为真。回到解析循环:
cpp
while (!(ip = parser.getNext()).empty())
{
trantor::InetAddress addr = parseAddress(ip);
if (addr.isUnspecified() || matchCidr(addr, trustCIDRs_)) continue; // ← IPv6 在这里被跳过
req->attributes()->insert(attributeKey_, addr);
return;
}
// 全链走完 → 回退 TCP 对端
req->attributes()->insert(attributeKey_, peerAddr);
所以 XFF 里的 IPv6 条目不是"解析成一个错地址",而是被静默丢弃 ,最终回退到 TCP 对端------也就是反向代理的地址。
后果和上面"trust_ips 填窄"是同一个形状 :反代后面,所有 IPv6 客户端收缩成同一个 key 。任何基于 GetRealAddr() 做的按 IP 限流(注册、登录频控)都会把整个 IPv6 群体当成一个客户端,而日志里一切正常。
在 dual-stack 部署下,这不是边角场景------那是相当大比例的流量。
(另外 matchCidr() 也是 v4-only:addr.ipNetEndian() 返回 32 位 in_addr_t,所以 v6 对端连"匹配可信 CIDR"这一步都做不到。)
改起来其实不难
trantor 已经把需要的东西都暴露了:
cpp
bool isIpV6() const;
uint32_t ipNetEndian() const; // v4
const uint32_t *ip6NetEndian() const; // v6: 4 x uint32 = 16 字节,网络序
std::string toIpNetEndian() const; // 按协议族返回:v4 → 4 字节 / v6 → 16 字节
(这里有个容易被卡住的点值得顺手说清:Drogon 的 CMakeLists.txt 不 pin trantor 版本号 ------USE_SUBMODULE ON 走 add_subdirectory(trantor),submodule 锁在 commit 8ff5b1bb。我拉了那个 commit 的 trantor/net/InetAddress.h 逐行核对,上面四个接口都在,所以不存在"最低版本才有"的阻塞。)
有个小技巧值得一提:toIpNetEndian() 让 matchCidr 可以完全绕开协议族分支------它按地址族返回定长网络序字节串,于是可以改走"字节前缀比较":
cpp
auto bytes = addr.toIpNetEndian();
if (bytes.size() != cidr.bytes_.size()) continue; // 跨协议族天然不匹配
size_t full = cidr.prefixLen_ / 8, rem = cidr.prefixLen_ % 8;
if (memcmp(bytes.data(), cidr.bytes_.data(), full) != 0) continue;
if (rem && ((uint8_t)bytes[full] ^ (uint8_t)cidr.bytes_[full]) >> (8 - rem)) continue;
return true;
收益是 v4/v6 走同一条路径(没有 isIpV6() 分支、也没有 #ifdef _WIN32 下 s6_addr32 的平台选型),而且 prefixLen_ == 32 时行为与旧的 32 位与运算逐位一致------回归风险最低。
⚠️ 这里有个必须注意的坑:字节长度只反映协议族,不反映"解析失败" 。解析失败的地址 sin_family 已经设好、只是 isUnspecified() 为真,toIpNetEndian() 照样返回定长字节串(内容全 0)。所以原来的 addr.isUnspecified() 检查必须保留,不能指望长度判断兜住。
一个容易被忽略的约束:friend class Hodor
RealIpResolver.h 里有 friend class Hodor;------Hodor 插件直接用了它的私有类型,具体是两处:
cpp
// Hodor::initAndStart ------ 构造
trustCIDRs_.emplace_back(ipOrCidr.asString()); // 用 CIDR 的构造函数
// Hodor::checkLimit ------ 匹配
if (RealIpResolver::matchCidr(ip, trustCIDRs_)) { return true; }
也就是说,只要保持 CIDR::CIDR(const std::string&)、CIDRs 类型名、matchCidr 签名这三个不动 ,Hodor 一行都不用改(只需重新编译)。CIDR 内部字段怎么改,它不关心。
这大幅降低了改动半径,但也意味着这三处签名不能顺手"优化"掉------否则一个"补功能"的 PR 就变成了跨模块重构,review 难度直接上一个台阶。
顺带一提:Hodor 自己的 trust_ips 处理继承同一个限制 ------它走的是同一个 CIDR 构造函数。
诚实边界
以上 IPv6 部分全部来自读 RealIpResolver.cc / trantor/net/InetAddress.{h,cc} 源码推导,我没有跑复现 。所以那句 Bad ipv4 address 的确切文案、以及"静默丢弃"的路径,请当作从代码推出的结论而不是实测观测。
我把这份分析提成了上游 issue:drogonframework/drogon#2596 ,正文里也明确标注了"未跑复现"。这个缺口从插件 2022-07 合并(PR #1321)至今一直如此,v1.9.10 和 master 在这块代码上完全一致。
8. 方案对比
| 方案 | 直连伪造 XFF | 反代后的真实 IP | 多级代理链 | 代码量 |
|---|---|---|---|---|
getPeerAddr() 直接用 |
安全(伪造无效) | ✗ 拿到代理 IP,频控全员共享一个 key | ✗ 同上 | 1 行 |
| 手取 XFF 首段(原实现) | ✗ 完全绕过 | ✗ 拿到伪造值 | ✗ 拿到伪造值 | 约 20 行 |
| 手取 XFF 末段 | 安全 | ✗ 多级代理时拿到倒数第二跳代理 | ✗ 同上 | 数行 |
RealIpResolver(现实现) |
安全(先验 peer) | ✓ 正确 | ✓ 从右往左逐个跳过 | 1 行 + 配置 |
只有最后一行同时满足"防伪造"和"反代后正确"。
为什么不把自己写的那版改对,而是直接用插件
手改一版其实不难------核心就是"验 peer + 从右往左"两段逻辑。但我选插件,理由是安全代码的维护成本是不对称的:
- 手写版要自己实现 CIDR 匹配 (
trust_ips通常是一整个网段而不是单个 IP)。我最初那版只做了单 IP 相等比较,看着能用,实际部署里网段一改就漏。 - 手写版还要处理边界:XFF 里混入非法 IP、端口后缀(
1.2.3.4:5678)、多余空格、超长链。官方的XForwardedForParser+parseAddress这些都已经覆盖。 - 最关键的是:这属于"我错了会挨打"的代码。放在上游,有更多人 review、更多场景压过;放在自己项目里,只有我一个人的盲区。
有意思的是,这段推理最后又回转了一次------即便是上游那份"更多人 review 过"的代码,IPv6 这一块依然是空白。 所以"用官方实现"能显著降低风险,但不等于可以把验证这一步省掉。
9. 三条可以迁移的东西
- 凡是"由请求方提供、你又拿它做安全决策"的值,都要先问一句"谁写的"。 XFF 首段、
Referer、X-Real-IP、甚至User-Agent都属这一类。它们的共同特征是:能编进代码、能跑通、看起来正常,直到有人故意不按规矩来。 - "能编过""压测全绿""手测正常"是三件事,且都不等于"验证过"。 尤其当测试路径和真实攻击路径不一样时(本项目:直连压测走回退分支,攻击走 XFF 分支),绿灯反而会制造虚假的安全感。验证一个安全修复,要设计一个它本该失败的反例。
- 配错一个安全参数,可能是"安全问题"也可能是"可用性问题",方向常常相反。
trust_ips填窄了是频控全量误伤(更难排查,因为日志正常),填宽了是伪造当场生效。先把语义读准,再决定填什么------别把它当白名单。
附:相关链接
- 项目仓库:
https://github.com/Hespethorn/seckill-cpp - 上游 issue(本文第 7 节的来源):
https://github.com/drogonframework/drogon/issues/2596 - 前一篇(注册安全加固 + IP 频控的初版实现,也就是本文勘误的对象):见仓库
docs/与博客系列 3.10
X-Forwarded-For 不是"客户端地址列表",而是一串自称 ------只有从可信代理那一侧往左数,第一个不可信的地址,才是可信的。
取 X-Forwarded-For 首段做频控 key,等于把限流开关交给了调用方
最近我在一个 C++ 秒杀项目里写了一段"取 X-Forwarded-For 首段"的代码,用来在反向代理后面拿到真实客户端 IP。这段代码编译通过、压测全绿、curl 手测正常。
它是错的。而且错得很危险:攻击者不需要任何工具,一行请求头就能让同 IP 注册频控彻底失效。
这篇把整个过程拆开讲------包括我为什么会写错(不是没查,是查错了地方)、正确做法在源码层面长什么样、为什么"能编过"根本不等于"验证过",以及顺着这条线往下挖时发现的一个上游框架缺口。
0. 结论先放最前面
如果你只想记四句话:
X-Forwarded-For是"逐跳自称"的地址链,首段由发起方控制,可任意伪造。 拿它当频控 key,等于把 key 交给攻击者。- Nginx 最常用的
$proxy_add_x_forwarded_for是追加语义,会把伪造值留在最左边------所以"我配了反代"并不能让首段变可信,反而可能放大伪造。 - 正确解析要两步:先验证直连方是不是可信代理(不是就完全不信 XFF),再从右往左跳过可信代理、取第一个不可信地址。 少任一步都不成立。
- 这段代码在直连压测下永远是绿的------因为直连时没有 XFF,代码走的是回退分支,拿到的就是真实地址。这个问题只在"真实部署 + 有人故意伪造"的组合下才会现形。
1. 起点:一段当时看起来完全正确的代码
场景是个 C++ 秒杀系统(Drogon 框架)。我在 3.10 那篇里加了"同 IP 注册频控"------key 取 reg:ip:<ip>,1 小时内最多成功注册 5 次,超限返回 429。
频控本身没问题,问题在 <ip> 从哪来。生产上服务在 Nginx 后面,getPeerAddr() 拿到的是 Nginx 的地址,所有用户会挤成一个 key,那频控就成了"全站一小时 5 次"。所以必须从 X-Forwarded-For 里取真实客户端 IP。
我当时写的是这个:
cpp
// 已废弃的错误实现(简化)
std::string clientIp(const drogon::HttpRequestPtr &req) {
if (auto xff = req->getHeader("x-forwarded-for"); !xff.empty()) {
auto pos = xff.find(',');
// 取第一个逗号之前的内容 = "最左首段"
return (pos == std::string::npos) ? xff : xff.substr(0, pos);
}
return req->getPeerAddr().toIp(); // 回退
}
这段代码的推理链条是这样的:
XFF 是"客户端 → 各级代理"的路径记录,那么最左边的应该就是最早的、也就是最原始的客户端。
听起来很顺。问题是它有一个隐含前提:链路上每个人都在说实话。
而且我当时还留了一句话写在博客里------"最左首段才是最原始客户端"。现在回头看,那不是"不够严谨",是把一个错误假设写成了结论。
我为什么会查错地方
这一点比 bug 本身更值得记。
我在业务代码里写 req->getClientIp(),编译报错。翻 HttpRequest.h,确实找不到这个方法。于是在当时的心智模型里得出了结论:Drogon 不内建从 XFF 解析真实 IP 的能力,只能自己写。
这个判断是错的。能力在,只不过不在 HttpRequest 上,而是独立插件 drogon::plugin::RealIpResolver(v1.9.10 起内置)。
教训:在框架 API 里搜不到某个能力时,"它不存在"和"它在别的地方"是两个完全不同的结论,而后者通常更接近事实。方法论上的正确动作是搜文档/仓库,而不是搜头文件------我跳过了这一步,因为"翻头文件没找到"给人的确认感太强了。
2. X-Forwarded-For 到底是谁写的
要理解坑在哪,先得看清这条链是怎么长出来的。
XFF 的语义是"每一跳把自己的上游地址记下来 "。真正可信的只有一件事:离你最近的那个 TCP 对端 ------那是内核告诉你的、伪造不了的连接来源。至于连接上带的那串 XFF,在验证之前它全是字符串。
markdown
【正常链路】
客户端(真实 1.2.3.4) ──▶ Nginx ──▶ 你的服务
(不带 XFF) X-Forwarded-For: 1.2.3.4
↑ 首段确实是真的
【伪造链路】
攻击者 ──▶ Nginx ──▶ 你的服务
(XFF 里自带 9.9.9.9) 追加真实地址 X-Forwarded-For: 9.9.9.9, 1.2.3.4
↑ 首段是伪造的
真实地址被挤到右边
注意关键点:两条链路的请求头"长得一模一样"------都是"一个地址"或"地址, 地址"的形式。服务端光看 XFF 的值,分不出哪条是真的。
这就是"取首段"最致命的盲点:它假设了客户端一定是诚实的。而现实里,攻击者就是客户端。
真实地址在哪里?在伪造链路里,1.2.3.4 是 Nginx 亲眼看到的 TCP 对端,攻击者伪造不了;在正常链路里,它就是首段。唯一的区别是它在链上的位置------而位置取决于"从哪一跳开始不可信"。
3. 复现:一行 header 绕掉同 IP 注册频控
因为 clientIp() 取的是首段,而首段完全由请求方控制,于是:
bash
# 每次换一个 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.1、reg: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 压测基线全绿,一次都没暴露这个问题。
压测能验证性能,验证不了"有人故意不按规矩发请求"。 安全假设必须单独想一遍------而这段代码在压测里给出的绿灯,反而给了我一种"这块已经测过了"的错觉。
4. 正确解析:两步,缺一不可
第一步:确认直连方是可信代理。 如果 TCP 对端本身不在可信代理名单里,那么 XFF 里的任何内容都不该采信------直接用 getPeerAddr()。这一步挡的是"直连伪造"。
第二步:从右往左遍历,跳过可信代理,取第一个不可信 IP。 右边那几段才是代理亲眼所见、攻击者够不着的。这一步挡的是"反代链路上的伪造"。
css
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,跳过
第 3 个:9.9.9.9 → 不可信 → 这就是"真实客户端"
如果整条链都是可信代理,或者链走完仍没有不可信地址
→ 回退到 getPeerAddr()(TCP 对端永远伪造不了)
这套逻辑不用自己写。Drogon 官方插件 RealIpResolver 就是干这个的,核心实现(lib/src/RealIpResolver.cc):
cpp
// 第一步:直连方不是可信代理 → 压根不看 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):
json
{
"name": "drogon::plugin::RealIpResolver",
"config": {
"trust_ips": [],
"from_header": "x-forwarded-for",
"attribute_key": "real-ip"
}
}
业务侧那 20 行手写解析整体删掉,缩成一行:
cpp
// src/controllers/UserController.cc
// 插件已完成「验可信代理 + 从右往左取首个不可信 IP」;
// 插件未注册时 GetRealAddr 内部回退 getPeerAddr(),属安全降级方向。
std::string clientIp(const drogon::HttpRequestPtr &req) {
return drogon::plugin::RealIpResolver::GetRealAddr(req).toIp();
}
顺带确认过它的降级行为是安全的:插件未注册、或 pre-routing 阶段没跑到时,GetRealAddr 内部会回退到 getPeerAddr()------最坏情况退化成"不用 XFF",而不是"乱用 XFF"。方向对了。
5. trust_ips 不是白名单,是"我信任谁的 XFF"
上面配置里 trust_ips: [] 这个空数组,是我纠正过一次认知之后才敢写的。
trust_ips 常被当成"IP 白名单"理解,但它真正的语义是:我信任谁的 X-Forwarded-For。你声明谁是代理,就等于把"由谁来决定频控 key"交了出去。
填错有两个方向 ,而代价完全不同------我最初只想到一个,甚至写过"填错不会放大安全风险"这种话。那句话只在"填窄"时成立,我把一半结论当成了全部。
| 填法 | 对端是否命中 | 插件行为 | 后果 | 性质 |
|---|---|---|---|---|
| 填窄(对端不在列表) | 否 | 忽略 XFF,退回 TCP 对端地址 | 伪造依然无效;但反代与业务不同机时,所有请求退化成同一个网关 IP → 同 IP 频控变成"全站一小时 5 次" | 可用性问题 |
| 填宽(把客户端也能连到的地址放进列表) | 是 | 采信该来源的 XFF,从右往左取首个不可信 IP | 伪造当场生效------花半小时修好的洞,一行配置就还回去了 | 安全问题 |
| 直连(前面没有反代) | --- | --- | 应填 []:列表为空则 matchCidr 永远不成立,XFF 一律忽略 |
正确配置 |
"填窄"那个方向的隐蔽性值得单独说:它是安全策略配置错误导致的全量误伤 ,比漏洞本身更难排查,因为日志里一切正常------服务活着、请求 200、只是频控在按一个共享 key 计数。
所以一个不存在的代理,不该出现在信任列表里。本项目当前(没有前置反代)就是 []。
6. 验证:为什么"编译过了"不算数
修完代码,我跑了静态检查 + 编译,全过。
但那时候我还不知道 reg:ip 的 key 到底会不会写成 127.0.0.1------因为我从没在"有人伪造 XFF"的情况下真的跑过一次注册。
下面是这段验证里踩的两个坑,比修复本身更值得记。
坑一:频控 key 只在"注册成功"那一刻才存在
我手敲了一版 curl 循环,三个响应全是 {"code":1,"msg":"CODE_EXPIRED"},Redis 里一条 reg:ip:* 都没有。
回去读 RegisterGuard 才明白:
cpp
// 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_EXPIRED,key 压根不会被创建。
扫出一个空集,看着"没问题",实际零信息量。这是验证脚本最容易骗到自己的方式:断言在"什么都没有"的前提下也成立。
要让验证有意义,必须让注册真的成功------临时把 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",照那个跑会直接误报"修复没生效"。
所以脚本改成跑两相 ,把 trust_ips 的契约摆到台面上:
ini
==================================================================
A 相:trust_ips = [](无代理 / 直连部署的正确配置)
==================================================================
[1] XFF=10.1.1.1 phone=13979614925 -> {"code":0,...,"msg":"success"}
[2] XFF=10.1.1.2 phone=13979614926 -> {"code":0,...,"msg":"success"}
[3] XFF=10.1.1.3 phone=13979614927 -> {"code":0,...,"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,...,"msg":"success"}
[2] XFF=10.1.1.2 phone=13779614928 -> {"code":0,...,"msg":"success"}
[3] XFF=10.1.1.3 phone=13779614929 -> {"code":0,...,"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},与伪造值一一对应
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} |
信任列表里的来源,其 XFF 说了算 |
B 相看着像"洞还在",其实不是------它只是把 trust_ips 的契约演示了一遍。这套配置本身没错,错的是把它用在"根本没有反代"的场景里。
本质一句话 :验证一个安全修复,不是"跑一遍看它没报错",而是设计一个它本该失败的反例,然后看它有没有真的失败。
7. 顺带发现:这个官方插件在 IPv6 下还有两个更隐蔽的缺口
上面那些都是自己的问题。但既然读了源码,顺手往下看了一层,发现这个插件只支持 IPv4------而且头文件里官方自己就承认了:
cpp
/**
* @note This plugin currently supports only ipv4 address or cidr.
*/
双栈部署(现在很常见)下,会撞上两个都不报错的失败。
缺口①:trust_ips 里填 IPv6,拿到的是误导性报错
CIDR 构造函数里其实专门为 IPv6 准备了一句异常:
cpp
trantor::InetAddress addr(ipv4, 0); // 第三个参数 ipv6 默认为 false
if (addr.isIpV6())
{
throw std::runtime_error("Ipv6 is not supported by RealIpResolver.");
}
if (addr.isUnspecified())
{
throw std::runtime_error("Bad ipv4 address: " + ipv4);
}
看起来很周全。但关键在于:trantor 的这个字符串构造函数并不识别协议族 ,isIpV6_ 直接取自第三个参数:
cpp
InetAddress::InetAddress(const std::string &ip, uint16_t port, bool ipv6)
: isIpV6_(ipv6) // ← 不看字符串内容,直接用传进来的值
{
...
if (::inet_pton(AF_INET, ip.c_str(), &addr_.sin_addr) <= 0)
{
return; // 提前返回,isUnspecified_ 保持 true
}
isUnspecified_ = false;
}
默认 ipv6 = false 时喂进 2001:db8::1:inet_pton(AF_INET, ...) 失败、提前 return,isUnspecified() 为真,而 isIpV6() 恒为 false。
于是结果是:那句 IPv6 专属的 throw 实际不可达,用户看到的是------
yaml
Bad ipv4 address: 2001:db8::1
一个合法但不受支持的 IPv6 地址,被说成了"地址格式错误"。这会把人往完全错误的方向带(去检查配置格式,而不是去查插件支持范围)。
更坑的一点:trantor 的头文件把这个构造标注为 @param ip A IPv4 or IPv6 address.------文档在提示它会自动识别,实现却没有。 这个不一致让"误以为自动识别"变得非常自然。
缺口②:X-Forwarded-For 里的 IPv6 条目会被静默丢弃
这个后果更严重。parseAddress() 用 find(':') 来切 host 和 port:
cpp
static trantor::InetAddress parseAddress(const std::string &addr)
{
auto pos = addr.find(':'); // ← IPv6 地址本身就含冒号
uint16_t port = 0;
if (pos == std::string::npos) return trantor::InetAddress(addr, 0);
try { port = std::stoi(addr.substr(pos + 1)); }
catch (const std::exception &ex) {
LOG_ERROR << "Error in ipv4 address: " + addr; // 报错文案也写死了 ipv4
port = 0;
}
return trantor::InetAddress(addr.substr(0, pos), port);
}
喂进 2001:db8::1:find(':') 命中第一个 冒号(位置 4)→ stoi("db8::1") 抛异常(被 catch,port 置 0)→ substr(0, 4) 得到 "2001" → 构造出 InetAddress("2001", 0)。
而 inet_pton(AF_INET, "2001") 同样失败,所以这个地址的 isUnspecified() 仍为真。回到解析循环:
cpp
while (!(ip = parser.getNext()).empty())
{
trantor::InetAddress addr = parseAddress(ip);
if (addr.isUnspecified() || matchCidr(addr, trustCIDRs_)) continue; // ← IPv6 在这里被跳过
req->attributes()->insert(attributeKey_, addr);
return;
}
// 全链走完 → 回退 TCP 对端
req->attributes()->insert(attributeKey_, peerAddr);
所以 XFF 里的 IPv6 条目不是"解析成一个错地址",而是被静默丢弃 ,最终回退到 TCP 对端------也就是反向代理的地址。
后果和上面"trust_ips 填窄"是同一个形状 :反代后面,所有 IPv6 客户端收缩成同一个 key 。任何基于 GetRealAddr() 做的按 IP 限流(注册、登录频控)都会把整个 IPv6 群体当成一个客户端,而日志里一切正常。
在 dual-stack 部署下,这不是边角场景------那是相当大比例的流量。
(另外 matchCidr() 也是 v4-only:addr.ipNetEndian() 返回 32 位 in_addr_t,所以 v6 对端连"匹配可信 CIDR"这一步都做不到。)
改起来其实不难
trantor 已经把需要的东西都暴露了:
cpp
bool isIpV6() const;
uint32_t ipNetEndian() const; // v4
const uint32_t *ip6NetEndian() const; // v6: 4 x uint32 = 16 字节,网络序
std::string toIpNetEndian() const; // 按协议族返回:v4 → 4 字节 / v6 → 16 字节
(这里有个容易被卡住的点值得顺手说清:Drogon 的 CMakeLists.txt 不 pin trantor 版本号 ------USE_SUBMODULE ON 走 add_subdirectory(trantor),submodule 锁在 commit 8ff5b1bb。我拉了那个 commit 的 trantor/net/InetAddress.h 逐行核对,上面四个接口都在,所以不存在"最低版本才有"的阻塞。)
有个小技巧值得一提:toIpNetEndian() 让 matchCidr 可以完全绕开协议族分支------它按地址族返回定长网络序字节串,于是可以改走"字节前缀比较":
cpp
auto bytes = addr.toIpNetEndian();
if (bytes.size() != cidr.bytes_.size()) continue; // 跨协议族天然不匹配
size_t full = cidr.prefixLen_ / 8, rem = cidr.prefixLen_ % 8;
if (memcmp(bytes.data(), cidr.bytes_.data(), full) != 0) continue;
if (rem && ((uint8_t)bytes[full] ^ (uint8_t)cidr.bytes_[full]) >> (8 - rem)) continue;
return true;
收益是 v4/v6 走同一条路径(没有 isIpV6() 分支、也没有 #ifdef _WIN32 下 s6_addr32 的平台选型),而且 prefixLen_ == 32 时行为与旧的 32 位与运算逐位一致------回归风险最低。
⚠️ 这里有个必须注意的坑:字节长度只反映协议族,不反映"解析失败" 。解析失败的地址 sin_family 已经设好、只是 isUnspecified() 为真,toIpNetEndian() 照样返回定长字节串(内容全 0)。所以原来的 addr.isUnspecified() 检查必须保留,不能指望长度判断兜住。
一个容易被忽略的约束:friend class Hodor
RealIpResolver.h 里有 friend class Hodor;------Hodor 插件直接用了它的私有类型,具体是两处:
cpp
// Hodor::initAndStart ------ 构造
trustCIDRs_.emplace_back(ipOrCidr.asString()); // 用 CIDR 的构造函数
// Hodor::checkLimit ------ 匹配
if (RealIpResolver::matchCidr(ip, trustCIDRs_)) { return true; }
也就是说,只要保持 CIDR::CIDR(const std::string&)、CIDRs 类型名、matchCidr 签名这三个不动 ,Hodor 一行都不用改(只需重新编译)。CIDR 内部字段怎么改,它不关心。
这大幅降低了改动半径,但也意味着这三处签名不能顺手"优化"掉------否则一个"补功能"的 PR 就变成了跨模块重构,review 难度直接上一个台阶。
顺带一提:Hodor 自己的 trust_ips 处理继承同一个限制 ------它走的是同一个 CIDR 构造函数。
诚实边界
以上 IPv6 部分全部来自读 RealIpResolver.cc / trantor/net/InetAddress.{h,cc} 源码推导,我没有跑复现 。所以那句 Bad ipv4 address 的确切文案、以及"静默丢弃"的路径,请当作从代码推出的结论而不是实测观测。
我把这份分析提成了上游 issue:drogonframework/drogon#2596 ,正文里也明确标注了"未跑复现"。这个缺口从插件 2022-07 合并(PR #1321)至今一直如此,v1.9.10 和 master 在这块代码上完全一致。
8. 方案对比
| 方案 | 直连伪造 XFF | 反代后的真实 IP | 多级代理链 | 代码量 |
|---|---|---|---|---|
getPeerAddr() 直接用 |
安全(伪造无效) | ✗ 拿到代理 IP,频控全员共享一个 key | ✗ 同上 | 1 行 |
| 手取 XFF 首段(原实现) | ✗ 完全绕过 | ✗ 拿到伪造值 | ✗ 拿到伪造值 | 约 20 行 |
| 手取 XFF 末段 | 安全 | ✗ 多级代理时拿到倒数第二跳代理 | ✗ 同上 | 数行 |
RealIpResolver(现实现) |
安全(先验 peer) | ✓ 正确 | ✓ 从右往左逐个跳过 | 1 行 + 配置 |
只有最后一行同时满足"防伪造"和"反代后正确"。
为什么不把自己写的那版改对,而是直接用插件
手改一版其实不难------核心就是"验 peer + 从右往左"两段逻辑。但我选插件,理由是安全代码的维护成本是不对称的:
- 手写版要自己实现 CIDR 匹配 (
trust_ips通常是一整个网段而不是单个 IP)。我最初那版只做了单 IP 相等比较,看着能用,实际部署里网段一改就漏。 - 手写版还要处理边界:XFF 里混入非法 IP、端口后缀(
1.2.3.4:5678)、多余空格、超长链。官方的XForwardedForParser+parseAddress这些都已经覆盖。 - 最关键的是:这属于"我错了会挨打"的代码。放在上游,有更多人 review、更多场景压过;放在自己项目里,只有我一个人的盲区。
有意思的是,这段推理最后又回转了一次------即便是上游那份"更多人 review 过"的代码,IPv6 这一块依然是空白。 所以"用官方实现"能显著降低风险,但不等于可以把验证这一步省掉。
9. 三条可以迁移的东西
- 凡是"由请求方提供、你又拿它做安全决策"的值,都要先问一句"谁写的"。 XFF 首段、
Referer、X-Real-IP、甚至User-Agent都属这一类。它们的共同特征是:能编进代码、能跑通、看起来正常,直到有人故意不按规矩来。 - "能编过""压测全绿""手测正常"是三件事,且都不等于"验证过"。 尤其当测试路径和真实攻击路径不一样时(本项目:直连压测走回退分支,攻击走 XFF 分支),绿灯反而会制造虚假的安全感。验证一个安全修复,要设计一个它本该失败的反例。
- 配错一个安全参数,可能是"安全问题"也可能是"可用性问题",方向常常相反。
trust_ips填窄了是频控全量误伤(更难排查,因为日志正常),填宽了是伪造当场生效。先把语义读准,再决定填什么------别把它当白名单。
附:相关链接
- 项目仓库:
https://github.com/Hespethorn/seckill-cpp - 上游 issue(本文第 7 节的来源):
https://github.com/drogonframework/drogon/issues/2596 - 前一篇(注册安全加固 + IP 频控的初版实现,也就是本文勘误的对象):见仓库
docs/与博客系列 3.10
X-Forwarded-For 不是"客户端地址列表",而是一串自称 ------只有从可信代理那一侧往左数,第一个不可信的地址,才是可信的。