验证码改成自签发 / 日志模式后(3.3),"送达"不再是外部约束——注册接口等于裸奔在公网:攻击者拿一堆手机号从同一个 IP 批量注册,就能刷出成千上万个账号。这篇补两道闸门:① 同 IP 注册频控堵批量刷号;② 反代真实客户端 IP 解析,否则频控的 key 拿到的是代理 IP,等于没防。

配套代码:src/service/RegisterGuard.{h,cc}check / markSuccess + kCheckScript / kMarkScript)、src/controllers/UserController.ccclientIp()UserService::registerUser 最前的频控闸门。阈值走 config.jsonregister_limitmax_per_ip=5 / window_seconds=3600)。

⚑ 2026-09-17 勘误(重要)
本文初版主张「取 X-Forwarded-For 首段即最原始客户端」,这个结论是错的。XFF 首段由调用方控制、可任意伪造:直连时一行 header 就能换掉频控 key(频控失效);反代时 Nginx 的 $proxy_add_x_forwarded_for追加语义,伪造值反而被拼在最左,取首段拿到的仍是伪造值。正确做法是先校验直连方是否为可信代理,再从右往左取首个不可信 IP——Drogon 官方 RealIpResolver 插件(v1.9.10 内置)已实现该逻辑。下文相关段落与代码已就地修正;完整的坑、攻击复现与原理见 3.11 X-Forwarded-For 取首段的陷阱:一次真实的注册频控绕过

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

是什么:注册链路在"手机号格式校验之前"先过一道同 IP 固定窗口频控RegisterGuard),限制一个 IP 在 window_seconds(默认 3600s)内能建成的账号数(max_per_ip,默认 5);超限直接 429。IP 本身由 UserController::clientIp() 提供——它内部调用 Drogon 官方 RealIpResolver 插件:先校验直连方是否为可信代理,命中才解析 X-Forwarded-For 并从右往左取首个不可信 IP。注意HttpRequest 确实没有 getClientIp(),但框架以插件形式内建了这个能力,初版漏掉它是本文最大的坑(见 3.11)。

坑在哪

  • 频控 key 拿错 IP:走 Nginx / 网关反代后,req->getPeerAddr() 拿到的是代理 IP,所有请求共享同一个 key,频控要么全放过、要么全误杀。必须解析 X-Forwarded-For——但不能盲目取首段:首段由调用方控制、可任意伪造,取了等于把频控 key 交出去(见 3.11)。
  • 误判"框架不提供该能力":曾有代码写 req->getClientIp(),编译直接挂——HttpRequest 确实只有 getPeerAddr(),没有 getClientIp() / getRealIp()。但据此断定"框架不内建从 XFF 解析真实 IP 的能力"是错的:答案是官方 RealIpResolver 插件,它不在 HttpRequest 的方法列表里,翻头文件容易漏掉。
  • 计尝试数而非成功数:计尝试数会被错误验证码刷爆配额(DoS 掉正常用户名额);本意是限制"一个 IP 能建几个号",不是"能试几次"。
  • Redis 挂了全拒:频控属可用性维度,Redis 不可用时若 fail-close,注册全断,损失比被刷号更大。

本质一句话:注册安全 = 反代真实 IP 作 key + Redis 共享计数 Lua 原子 + 固定窗口限成功数 + fail-open——IP 取错一切归零,计数必须共享、判定必须原子、限流必须放松(宁可放过)

二、clientIp():交给官方 RealIpResolver,别自己抠首段

真实部署里请求都过一层反代,req->getPeerAddr() 拿到的是代理 IP,所以必须看 X-Forwarded-For(XFF)。但 XFF 的语义比"取首段"复杂得多

XFF 由每一跳追加自己的地址,形如 client, proxy1, proxy2——坑在于最左那段是"客户端自称"的地址:任何调用方都能自己发一个 X-Forwarded-For: 1.2.3.4 把它写死;而 Nginx 最常见的配置 $proxy_add_x_forwarded_for 又是追加而非覆盖,伪造值会被原样拼在最左、真实地址反而排在后面。

正确姿势是两道保险:① 先看直连方(TCP 对端)是不是可信代理,不是就完全不看 XFF;② 是的话,从右往左跳过可信代理链、取第一个不可信 IP。Drogon 官方 RealIpResolver 插件做的就是这件事,v1.9.10 起内置。

下面这张图是「客户端未伪造 XFF」的理想情形——也正是初版误以为"取首段就够了"的原因:

真实客户端 1.2.3.4 Nginx 反代 10.0.0.1 Drogon 服务 getPeerAddr=10.0.0.1 请求头 X-Forwarded-For: 1.2.3.4, 10.0.0.1 clientIp() → 取首段 → 1.2.3.4(仅客户端未伪造 XFF 时成立) getPeerAddr() → 10.0.0.1 ✗ 代理 IP(频控用它会全员共享一个 key)
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();
}

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 的语义是**「我信任谁的 X-Forwarded-For」。本项目当前不挂反向代理**,所以取 []——列表为空则 matchCidr 永远不成立,XFF 一律被忽略,伪造没有入口;将来接入 nginx 再把它的地址填进去。

反代侧仍需配 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;(让链路可回溯);但请注意它是追加语义——正因为如此,服务端绝不能取首段,必须按"验可信代理 + 从右往左"解析。另外,接入反代后记得在 config.jsontrust_ips 里登记反代地址,否则插件不会采信 XFF——真实 IP 取不到,所有请求会退化成同一个代理 IP,同 IP 频控误伤全部用户。但也别把范围填宽:把客户端也能连到的地址写进列表,等于让那个来源决定频控 key,伪造当场生效。

三、RegisterGuard:固定窗口限"成功注册数"

为什么计成功数而不计尝试数?计尝试数会被错误验证码刷爆配额(攻击者随便填码就能把正常用户的名额耗尽),本质是 DoS;计成功数直接约束"一个 IP 能建几个账号",这才是真正的威胁面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// src/service/RegisterGuard.h
// 同 IP 注册频控:固定窗口内允许的成功注册次数。
class RegisterGuard {
public:
RegisterGuard(drogon::nosql::RedisClientPtr redis, int maxPerIp, int windowSeconds);

// 闸门:当前 IP 是否还能注册。cb(allowed)
// allowed=true → 未达上限,放行
// allowed=false → 已达上限,应返回 429
// Redis 不可用时 fail-open:放行(宁可放过,不可误杀正常注册)。
void check(const std::string &ip, std::function<void(bool)> &&cb);

// 注册成功后调用:计数 +1,首次设置窗口 TTL(固定窗口语义)。
void markSuccess(const std::string &ip, std::function<void()> &&cb = {});

static std::string keyFor(const std::string &ip) { return "reg:ip:" + ip; }
private:
drogon::nosql::RedisClientPtr redis_;
int maxPerIp_; // 默认 5(config.json register_limit.max_per_ip)
int windowSeconds_; // 默认 3600(config.json register_limit.window_seconds)
};

计数放 Redis 而非进程内存:多实例部署时进程内存只对单机有效,打散到 N 台实例就形同虚设,Redis 是共享单一计数源(与 3.6 的 LoginGuard 同理)。

四、两段 Lua:纯读比对 + INCR 定窗

check 的脚本只比对不改计数(真正的 +1 只在 markSuccess 成功注册时做),避免"判定可注册"和"记一次数"跨两次调用的竞态。markSuccess 的脚本 INCR 后,仅在 n == 1(窗口内首次)设置 EXPIRE——这就是固定窗口:窗口内第一个成功注册创建计数键并定长过期,之后同 IP 只累加,过期后自动清零。

客户端 UserController clientIp() UserService registerUser RegisterGuard Redis EVAL kCheckScrit allowed=true → 落库 → markSuccess → EVAL kMarkScript(INCR+EXPIRE) allowed=false → 429 REGISTER_IP_LIMITED(连手机号校验都不进)
1
2
3
4
5
-- kCheckScript(KEYS[1]=key;ARGV[1]=maxPerIp)纯读比对,不改计数
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 -- 0=已达上限
1
2
3
4
-- kMarkScript(KEYS[1]=key;ARGV[1]=windowSeconds)INCR,首次定窗
local n = redis.call('INCR', KEYS[1])
if n == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
return n
1
2
3
4
5
6
7
8
9
10
11
// src/service/RegisterGuard.cc
void RegisterGuard::check(const std::string &ip, std::function<void(bool)> &&cb) {
const std::string key = keyFor(ip);
redis_->execCommandAsync(
[cb](const drogon::nosql::RedisResult &r) { cb(r.asInteger() == 1); },
[cb, ip](const std::exception &e) { // 异常 → fail-open
SK_LOG_ERROR << "REG_IP_CHECK_FAILED ip=" << ip << " err=" << e.what();
cb(true);
},
"EVAL %s 1 %s %d", kCheckScript, key.c_str(), maxPerIp_);
}

五、fail-open vs fail-close:与 3.6 同一条红线

RegisterGuard::check 在 Redis 异常时的取舍,与 3.6 的 LoginGuard::checkLocked 一致、与 SessionStore::exists 故意相反

  • 注册频控 check:fail-open(放行)。频控属"可用性"维度保护,Redis 挂掉时把全部注册都拒掉,业务损失比被刷号更大;且刷号本身还有验证码在兜。
  • 会话校验 SessionStore::exists:fail-close(拒绝)。鉴权是"安全性"维度,宁可误杀已登录用户让其重登,也不能在 Redis 抖动时放行进被封禁的会话。

一句话:鉴权要收紧(宁可误杀),限流要放松(宁可放过)——两条红线不能混,这是安全设计里最反直觉也最容易踩的点。

六、与主链路集成:闸门最前 + 429

频控闸门放在 UserService::registerUser第一行——被限的 IP 连手机号格式校验、验证码校验都不该再消耗后续资源。只有真正落库成功的那一刻才 markSuccess(ip) 计一次数(失败回滚不计数)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// src/service/UserService.cc(registerUser 开头)
void UserService::registerUser(const std::string &phone, const std::string &password,
const std::string &code, const std::string &ip,
std::function<void(bool, const std::string &)> &&cb) {
// 同 IP 注册频控闸门:固定窗口内只允许有限个成功注册。放最前面。
registerGuard_->check(ip,
[this, phone, password, code, ip, cb = std::move(cb)](bool allowed) mutable {
if (!allowed) { // 达上限 → 直接拒
SK_LOG_WARN << "REGISTER_IP_LIMITED ip=" << ip;
cb(false, "REGISTER_IP_LIMITED"); // 由 statusForFailure 映射成 429
return;
}
if (!SmsService::isValidPhone(phone)) { cb(false, "INVALID_PHONE"); return; }
// ……查重 → 加盐哈希 → 落库 ……
// 落库成功后:
registerGuard_->markSuccess(ip); // 窗口计数 +1(首次自动定窗)
cb(true, "OK");
});
}

UserController::statusForFailureREGISTER_IP_LIMITED 映射成 HTTP 429(频控触发,客户端应放缓后重试),与业务拒绝 409、系统错误 500 严格区分。

一个有意的取舍:因为闸门在最前、先于查重,所以一个 IP 成功注册满 5 次后,连"重复注册"都会被 429 挡掉,到不了 PHONE_REGISTERED 分支。这是安全设计(频控优先于查重,批量刷号防护更稳);若你更希望"已注册用户重复提交"被回以 PHONE_REGISTERED,需把查重提到 IP 闸门之前——但会削弱批量刷号防护,本项目暂不动。

七、对比表

维度 进程内存计数 getPeerAddr() 作 key Redis + Lua(本文)
多实例语义 失效(5×N 次才限) 全员共享代理 IP key 共享单一态,不变
取 IP 正确性 错(拿到代理 IP) 对(RealIpResolver:验可信代理 + 从右往左取首个不可信 IP)
计数竞态 有(并发少计) 无(Lua 原子)
Redis 挂了 频控 fail-open,不阻断注册
计数清零 需额外定时 固定窗口 TTL 自动清零

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

① 为什么不能只用 getPeerAddr(),也不该自己抠 XFF 首段?
因为手上的 Drogon 1.9.10 根本没有 getClientIp()——HttpRequest 只暴露 getPeerAddr()(TCP 对端)。走反代时它就是代理 IP,直接拿来做频控 key 会让所有用户"共享一个配额",防护形同虚设。但"框架不提供该能力"是误判——答案是官方 RealIpResolver 插件(它不在 HttpRequest 的方法列表里,翻头文件容易漏掉)。而自己解析时取首段同样是错的:首段由调用方控制、可任意伪造,正确姿势是"验可信代理 + 从右往左取首个不可信 IP",插件的实现正是如此。这个双重误判,就是本篇最该记的坑。

② 为什么计"成功注册数"而不是"尝试次数"?
计尝试数意味着攻击者用一串错误验证码就能把配额刷爆,把正常用户的注册名额活活 DoS 掉;而真正要防的是"一个 IP 能建几个账号"。计成功数直接约束威胁面,且不给攻击者用错误请求消耗配额的机会。

③ 为什么 Redis 挂了要 fail-open(放行)而不是 fail-close(拒绝)?
频控和鉴权保护的是不同维度:频控是"可用性"(放过比拒掉损失小),鉴权是"安全性"(拒掉比放过损失小)。把频控设成 fail-close,Redis 一抖全员注册不了,业务损失远大于被刷几个号;刷号本身还有验证码这层兜底。两者红线不能混。

小结

  • 注册安全双保险 = clientIp() 取反代真实 IP(走官方 RealIpResolver 插件,而非手抠 XFF 首段)+ RegisterGuard 同 IP 固定窗口限成功注册数max_per_ip=5 / window_seconds=3600)。
  • 频控 key 用 reg:ip:<ip>check 纯读 Lua 比对、markSuccessINCR + 首次 EXPIRE 实现固定窗口;Redis 挂时 fail-open 放行,超限回 429 REGISTER_IP_LIMITED
  • 闸门放 UserService::registerUser 最前,先于手机号校验与查重;只有真正落库成功才计数——这是"频控优先于查重"的有意设计。
  • Drogon 1.9.10 的 HttpRequest getClientIp(),但框架以官方插件 RealIpResolver 内建了从 XFF 解析真实 IP 的能力——搜不到不代表没有,翻方法列表最容易漏掉。X-Forwarded-For 取首段可被伪造,必须"验可信代理 + 从右往左",否则频控等于把 key 交给调用方。🐾

配套源码

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

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

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

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