取 `X-Forwarded-For` 首段做频控 key,等于把限流开关交给了调用方

X-Forwarded-For 首段做频控 key,等于把限流开关交给了调用方

最近我在一个 C++ 秒杀项目里写了一段"取 X-Forwarded-For 首段"的代码,用来在反向代理后面拿到真实客户端 IP。这段代码编译通过、压测全绿、curl 手测正常。

它是错的。而且错得很危险:攻击者不需要任何工具,一行请求头就能让同 IP 注册频控彻底失效。

这篇把整个过程拆开讲------包括我为什么会写错(不是没查,是查错了地方)、正确做法在源码层面长什么样、为什么"能编过"根本不等于"验证过",以及顺着这条线往下挖时发现的一个上游框架缺口。


0. 结论先放最前面

如果你只想记四句话:

  1. X-Forwarded-For 是"逐跳自称"的地址链,首段由发起方控制,可任意伪造。 拿它当频控 key,等于把 key 交给攻击者。
  2. Nginx 最常用的 $proxy_add_x_forwarded_for 是追加语义,会把伪造值留在最左边------所以"我配了反代"并不能让首段变可信,反而可能放大伪造。
  3. 正确解析要两步:先验证直连方是不是可信代理(不是就完全不信 XFF),再从右往左跳过可信代理、取第一个不可信地址。 少任一步都不成立。
  4. 这段代码在直连压测下永远是绿的------因为直连时没有 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.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 压测基线全绿,一次都没暴露这个问题。

压测能验证性能,验证不了"有人故意不按规矩发请求"。 安全假设必须单独想一遍------而这段代码在压测里给出的绿灯,反而给了我一种"这块已经测过了"的错觉。


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_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",照那个跑会直接误报"修复没生效"。

所以脚本改成跑两相 ,把 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::1inet_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::1find(':') 命中第一个 冒号(位置 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 ONadd_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 _WIN32s6_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.10master 在这块代码上完全一致。


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. 三条可以迁移的东西

  1. 凡是"由请求方提供、你又拿它做安全决策"的值,都要先问一句"谁写的"。 XFF 首段、RefererX-Real-IP、甚至 User-Agent 都属这一类。它们的共同特征是:能编进代码、能跑通、看起来正常,直到有人故意不按规矩来。
  2. "能编过""压测全绿""手测正常"是三件事,且都不等于"验证过"。 尤其当测试路径和真实攻击路径不一样时(本项目:直连压测走回退分支,攻击走 XFF 分支),绿灯反而会制造虚假的安全感。验证一个安全修复,要设计一个它本该失败的反例。
  3. 配错一个安全参数,可能是"安全问题"也可能是"可用性问题",方向常常相反。 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. 结论先放最前面

如果你只想记四句话:

  1. X-Forwarded-For 是"逐跳自称"的地址链,首段由发起方控制,可任意伪造。 拿它当频控 key,等于把 key 交给攻击者。
  2. Nginx 最常用的 $proxy_add_x_forwarded_for 是追加语义,会把伪造值留在最左边------所以"我配了反代"并不能让首段变可信,反而可能放大伪造。
  3. 正确解析要两步:先验证直连方是不是可信代理(不是就完全不信 XFF),再从右往左跳过可信代理、取第一个不可信地址。 少任一步都不成立。
  4. 这段代码在直连压测下永远是绿的------因为直连时没有 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.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 压测基线全绿,一次都没暴露这个问题。

压测能验证性能,验证不了"有人故意不按规矩发请求"。 安全假设必须单独想一遍------而这段代码在压测里给出的绿灯,反而给了我一种"这块已经测过了"的错觉。


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_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",照那个跑会直接误报"修复没生效"。

所以脚本改成跑两相 ,把 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::1inet_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::1find(':') 命中第一个 冒号(位置 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 ONadd_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 _WIN32s6_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.10master 在这块代码上完全一致。


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. 三条可以迁移的东西

  1. 凡是"由请求方提供、你又拿它做安全决策"的值,都要先问一句"谁写的"。 XFF 首段、RefererX-Real-IP、甚至 User-Agent 都属这一类。它们的共同特征是:能编进代码、能跑通、看起来正常,直到有人故意不按规矩来。
  2. "能编过""压测全绿""手测正常"是三件事,且都不等于"验证过"。 尤其当测试路径和真实攻击路径不一样时(本项目:直连压测走回退分支,攻击走 XFF 分支),绿灯反而会制造虚假的安全感。验证一个安全修复,要设计一个它本该失败的反例。
  3. 配错一个安全参数,可能是"安全问题"也可能是"可用性问题",方向常常相反。 trust_ips 填窄了是频控全量误伤(更难排查,因为日志正常),填宽了是伪造当场生效。先把语义读准,再决定填什么------别把它当白名单。

附:相关链接

  • 项目仓库:https://github.com/Hespethorn/seckill-cpp
  • 上游 issue(本文第 7 节的来源):https://github.com/drogonframework/drogon/issues/2596
  • 前一篇(注册安全加固 + IP 频控的初版实现,也就是本文勘误的对象):见仓库 docs/ 与博客系列 3.10

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

相关推荐
米糕闯编程1 小时前
鱼香ros2(七)功能包组织C++节点
c++·ros2·cmakelists
汉克老师1 小时前
GESP2026年9月认证C++四级( 第三部分编程题(1、新汉诺塔))精讲
c++·gesp·小学生·学c++编程
唠玖馆3 小时前
智能指针详解
c++
ShineWinsu3 小时前
对于Git:基础操作的超详细保姆级解析
linux·c++·git·面试·备份·管理·版本控制器
小小de风呀3 小时前
de风——【从零开始学C++】(二十)红黑树封装map和set
c++·学习
青梅橘子皮3 小时前
Blue---枚举
c++
一木 之林4 小时前
第 8 节 C++ 设计模式在 AI 推理 SDK 和 Agent 工具运行时中如何落地
c++·人工智能·设计模式
神仙别闹5 小时前
基于 C++ 实现(控制台)考试报名系统
c++
weixin_307779135 小时前
C++代码实现MATLAB中的ode23t函数功能
开发语言·c++·算法·matlab