fix(gateway,smtp): 修复邮件轰炸漏洞(审计 P0-1)

gateway v0.5.3 → v0.5.4:

1. gateway/src/main.cpp
   - /register/send_code 强制 CAPTCHA 校验(经 captcha:8009,Token 一次性消费,fail-closed)
   - 新增邮箱级双窗口限流(3 次/小时 + 10 次/天,CAPTCHA 通过后才计入配额)
   - 新增 _EMAIL_ADDR_RATE_HOUR / _EMAIL_ADDR_RATE_DAY 限流器实例

2. smtp/smtp_v2.py
   - 新增邮箱级长期限流兜底(EMAIL_HOURLY_LIMIT / EMAIL_DAILY_LIMIT,默认 3/10)
   - 新增全局熔断(SMTP_HOURLY_LIMIT,默认 100 封/小时,防 smtp 账号被封)
   - 预占/回滚与 60s 冷却同模式,发送失败不计入配额

3. gateway/docs/SECURITY_AUDIT_20260801.md
   - 新增带日期的安全审计报告
   - P0-1 标记为已修复 v0.5.4

4. gateway/README.md
   - 更新 /register/send_code 接口文档(CAPTCHA 必填 + 5 层防护)
   - 更新版本变更日志

防护层次:IP 限流 → CAPTCHA → 邮箱级限流(gateway)→ 60s 冷却 → 邮箱级限流(smtp)→ 全局熔断
This commit is contained in:
qing committed 2026-08-01 02:37:15 +08:00
1 parent 2774c3471f
commit 49f0698b9d
4 files changed
+424 -7

No files matched your search

+20 -5
View File
@@ -10,7 +10,8 @@ UniServe Framework 的应用层网关,C++20 实现。
- **反向代理**:携带 Token 的请求校验后转发到 `Proxy.opened` 中放开的内部服务。 - **反向代理**:携带 Token 的请求校验后转发到 `Proxy.opened` 中放开的内部服务。
- **CAPTCHA 接口**:`/captcha` 生成验证码 token + 文本(内存存储,5 分钟过期,一次性使用)。 - **CAPTCHA 接口**:`/captcha` 生成验证码 token + 文本(内存存储,5 分钟过期,一次性使用)。
- **邮箱验证码**:`/register/send_code` 发送 6 位验证码邮件(10 分钟有效);注册时校验,一次性消费。 - **邮箱验证码**:`/register/send_code` 发送 6 位验证码邮件(10 分钟有效);注册时校验,一次性消费。
- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟、发验证码 5 次/10 分钟,按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入),超限一律返回 429。 - **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟、发验证码 5 次/10 分钟(IP 级)+ 3 次/小时/10 次/天(邮箱级),按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入),超限一律返回 429。
- **邮件轰炸防护**:`/register/send_code` 强制 CAPTCHA 校验 + 邮箱级双窗口限流(3 次/小时 + 10 次/天),smtp 层兜底同样限流 + 全局熔断(默认 100 封/小时,可配)。
- **OPTIONS 预检**:通配 `*` 返回 CORS 头,允许跨域。 - **OPTIONS 预检**:通配 `*` 返回 CORS 头,允许跨域。
## 安全特性 ## 安全特性
@@ -86,11 +87,24 @@ UniServe Framework 的应用层网关,C++20 实现。
向目标邮箱发送注册验证码(经 pymsg 调 smtp:8008 `send_email`,10 分钟有效)。 向目标邮箱发送注册验证码(经 pymsg 调 smtp:8008 `send_email`,10 分钟有效)。
**请求体**(CAPTCHA 必填,防邮件轰炸):
```json ```json
{ "email": "user@example.com" } {
"email": "user@example.com",
"captcha_token": "a1b2c3d4...",
"captcha_answer": "Ab3xy"
}
``` ```
限流 5 次/10 分钟/IP;同一邮箱 60 秒发送冷却(由 smtp 服务执行,冷却期拒绝时返回 `retry_after` 剩余秒数与提示文案,gateway 按 429 透传;已发出且在有效期内的验证码不受影响,发送失败不计入冷却)。错误码:`400`(邮箱格式错误)、`429`(IP 速率超限或同邮箱发送冷却)、`502`(smtp 服务不可用)、`500`(发送失败)。 **防护层次**:
1. IP 限流:5 次/10 分钟(`X-Forwarded-For`)
2. CAPTCHA 强制校验:经 captcha:8009 pymsg 校验,Token 一次性消费
3. 邮箱级双窗口限流:3 次/小时 + 10 次/天
4. smtp 同邮箱 60 秒发送冷却(冷却期拒绝时返回 `retry_after` 剩余秒数与提示文案,gateway 按 429 透传;已发出且在有效期内的验证码不受影响,发送失败不计入冷却)
5. smtp 邮箱级长期限流兜底 + 全局熔断(默认 100 封/小时,`SMTP_HOURLY_LIMIT` 可配)
错误码:`400`(邮箱格式错误/CAPTCHA 缺失或错误)、`429`(IP 速率超限/邮箱级限流超限/同邮箱发送冷却)、`502`(smtp 服务不可用)、`500`(发送失败)。
### `GET/POST *`(反向代理) ### `GET/POST *`(反向代理)
@@ -226,14 +240,15 @@ gateway/
## 版本 ## 版本
`0.5.3`(见 [src/main.cpp:30](src/main.cpp#L30))。启动时打印: `0.5.4`(见 [src/main.cpp:31](src/main.cpp#L31))。启动时打印:
``` ```
Gateway 0.5.3 started Gateway 0.5.4 started
``` ```
近期变更: 近期变更:
- `0.5.4`:修复邮件轰炸漏洞(审计报告 P0-1)——`/register/send_code` 强制 CAPTCHA 校验(经 captcha:8009,Token 一次性消费),新增邮箱级双窗口限流(3 次/小时 + 10 次/天);smtp 层同步增加邮箱级长期限流兜底 + 全局熔断(`SMTP_HOURLY_LIMIT` 环境变量,默认 100 封/小时)。
- `0.5.3`:配合 smtp 同邮箱 60 秒发送冷却——`/register/send_code` 在 smtp 返回 `retry_after` 时按 429 透传其提示文案(其余发送失败仍用通用 500 文案,SMTP 内部错误细节不向外暴露)。 - `0.5.3`:配合 smtp 同邮箱 60 秒发送冷却——`/register/send_code` 在 smtp 返回 `retry_after` 时按 429 透传其提示文案(其余发送失败仍用通用 500 文案,SMTP 内部错误细节不向外暴露)。
- `0.5.2`:限流错误码统一为 429(登录原 400、注册/发码原 403,并修正 "attampts" 拼写);登录时用户不存在不再兜底 500——空结果统一走 401,并执行 dummy PBKDF2 校验拉平时序防用户名枚举;db 查询失败消息由误名的 "Login (Update Step) Failed" 改为 "Login (Query Step) Failed";pymsg 下游调用超时常量化(`DbC::DEFAULT_TIMEOUT`、`_TIMEOUT_CAPTCHA/_SMTP_VERIFY/_SMTP_SEND`)。 - `0.5.2`:限流错误码统一为 429(登录原 400、注册/发码原 403,并修正 "attampts" 拼写);登录时用户不存在不再兜底 500——空结果统一走 401,并执行 dummy PBKDF2 校验拉平时序防用户名枚举;db 查询失败消息由误名的 "Login (Update Step) Failed" 改为 "Login (Query Step) Failed";pymsg 下游调用超时常量化(`DbC::DEFAULT_TIMEOUT`、`_TIMEOUT_CAPTCHA/_SMTP_VERIFY/_SMTP_SEND`)。
- `0.5.1`:登录成功后通过 db 更新 `Users.last_login`(`"$now"` 哨兵,db 日期适配层转为服务端当前 UTC 时间;更新失败经 THROW_EX_CUSTOM 抛 500,与其他步骤一致)。 - `0.5.1`:登录成功后通过 db 更新 `Users.last_login`(`"$now"` 哨兵,db 日期适配层转为服务端当前 UTC 时间;更新失败经 THROW_EX_CUSTOM 抛 500,与其他步骤一致)。
+323
View File
@@ -0,0 +1,323 @@
# gateway 安全审计报告
- **审计日期**:2026-08-01
- **审计版本**:gateway 0.5.3(见 [src/main.cpp:31](../src/main.cpp#L31))
- **审计范围**:
- [gateway/src/main.cpp](../src/main.cpp)(约 1700 行,全部业务实现)
- [gateway/inc/DbC.hpp](../inc/DbC.hpp)、[LogC.hpp](../inc/LogC.hpp)、[ExpBase.hpp](../inc/ExpBase.hpp)、[LocalTime.hpp](../inc/LocalTime.hpp)、[Msg.hpp](../inc/Msg.hpp)、[Conf.hpp](../inc/Conf.hpp)
- 依赖库 [libcrypt/inc/us/MyRSA.hpp](../../libcrypt/inc/us/MyRSA.hpp)、[Base64.hpp](../../libcrypt/inc/us/Base64.hpp)
- 配置 [gateway/example.ini](../example.ini)、构建 [gateway/Makefile](../Makefile)
- **审计方法**:静态代码审查 + 数据流分析 + 威胁建模
- **总体评价**:架构清晰、安全意识较强(常数时间比较、dummy PBKDF2 防用户名枚举、`what()` 屏蔽源码路径、路径规范化校验、可伪造头剥离),但存在若干可被实际利用的安全缺陷。按严重程度分级如下。
---
## P0 严重(建议立即修复)
### P0-1 邮件轰炸:`/register/send_code` 缺 CAPTCHA 强制校验【已修复 v0.5.4】
- **位置**:[main.cpp:1387](../src/main.cpp#L1387) `handleSendEmailCode`
- **问题**:该接口仅靠 IP 限流(5 次/10 分钟),无任何人机校验。攻击者可:
- 在模式 B(独立部署)下伪造 `X-Forwarded-For` 即可绕过限流(见 P0-3),向任意邮箱发送大量验证码邮件。
- 即使模式 A,也可用代理池/IP 轮换绕过 IP 限流。
- **影响**:smtp 账号被服务商封禁、受害者邮箱被轰炸。
- **修复(v0.5.4,2026-08-01)**:实施分层防护:
1. **gateway 层 CAPTCHA 强制校验**:经 captcha:8009 pymsg 校验,Token 一次性消费,fail-closed。
2. **gateway 层邮箱级双窗口限流**:3 次/小时 + 10 次/天(CAPTCHA 通过后才计入配额)。
3. **smtp 层邮箱级长期限流兜底**:同样 3 次/小时 + 10 次/天,作为 gateway 被绕过/重启时的最后防线。
4. **smtp 层全局熔断**:`SMTP_HOURLY_LIMIT` 环境变量(默认 100 封/小时),防 smtp 账号被服务商封禁。
- 改动文件:[gateway/src/main.cpp](../src/main.cpp)(新增 `_EMAIL_ADDR_RATE_HOUR`/`_EMAIL_ADDR_RATE_DAY` + `handleSendEmailCode` 强制 CAPTCHA + 邮箱限流)、[smtp/smtp_v2.py](../../smtp/smtp_v2.py)(新增 `send_log`/`global_send_log` + 限流检查 + 全局熔断 + 预占/回滚)。
### P0-2 注册接口 CAPTCHA 可选,可被批量注册
- **位置**:[main.cpp:1331-1338](../src/main.cpp#L1331)
- **问题**:注册流程中 CAPTCHA 仅在客户端「主动提供」时才校验:
```cpp
if (captcha_it != obj.end() && captcha_ans_it != obj.end()) {
// 校验
}
```
自动化脚本不传 `captcha_token` 即可跳过人机验证,配合获取邮箱验证码即可批量注册垃圾账号。注意 `/login` 在 [main.cpp:800](../src/main.cpp#L800) 是强制要求 CAPTCHA 的,行为不一致。
- **建议**:CAPTCHA 改为必填(与 `/login` 行为对齐)。
### P0-3 模式 B(独立部署)下 XFF 完全可伪造,所有限流失效
- **位置**:[main.cpp:776-780](../src/main.cpp#L776)、[1298-1301](../src/main.cpp#L1298)、[1107-1110](../src/main.cpp#L1107)、[1395-1398](../src/main.cpp#L1395)
- **问题**:全部限流均信任 `X-Forwarded-For` 头:
```cpp
auto it_host = req.base().find("X-Forwarded-For");
if (it_host != req.base().end())
client_ip = std::string(it_host->value());
else
client_ip = "direct";
```
模式 B 下 gateway 直接面向公网,客户端每次发不同 XFF,限流计数器 key 全部不同,等于无限流。README 承认此点(「RateLimiter 取不到 X-Forwarded-For,所有请求记为 direct」),但实际代码是「客户端发啥信啥」,比文档描述更严重。
- **影响**:登录爆破、注册轰炸、Token 刷新滥用全部失防。
- **建议**:启动时根据 `_TLS` 选择 IP 来源策略——模式 B 下用 socket 真实源 IP(`Remote-Addr`),不信 XFF;或在 `is_special_header` 已剥离客户端 XFF 的基础上,模式 B 下不再回填。
### P0-4 PBKDF2 / RAND_bytes 返回值未检查
- **位置**:[main.cpp:66-75](../src/main.cpp#L66) `hash_password`、[main.cpp:97-103](../src/main.cpp#L97) `verify_password`
- **问题**:
```cpp
RAND_bytes(salt, SALT_LEN); // 返回值未检查
PKCS5_PBKDF2_HMAC(..., hash); // 返回值未检查
```
极端情况(如 `/dev/urandom` 不可用、内存耗尽)下,salt/hash 为未初始化栈数据,导致:
- 密码哈希可预测(若 salt 全 0)。
- 验证逻辑用未初始化数据做常数时间比较,行为未定义。
OpenSSL 文档明确要求检查返回值。
- **建议**:
```cpp
if (RAND_bytes(salt, SALT_LEN) != 1)
throw std::runtime_error("RAND_bytes failed");
if (PKCS5_PBKDF2_HMAC(...) != 1)
throw std::runtime_error("PBKDF2 failed");
```
---
## P1 高(应在下一版本修复)
### P1-1 Token 无撤销机制,logout 仅清 Cookie
- **位置**:[main.cpp:944](../src/main.cpp#L944) `handleLogout`
- **问题**:`handleLogout` 只设置 `Max-Age=0` 的 Cookie,Token 本身仍然有效。Token 一旦泄漏(如 XSS 抓到 Token 值、日志记录、代理缓存),在 `keep_time`(默认 1 小时)内无法撤销,攻击者可继续使用。`refresh_token` 还把可用窗口拉长到 `iat + keep_time * 2`(默认 2 小时)。
- **建议**:
- Token payload 增加 `jti`(JWT ID)字段。
- 维护内存黑名单(Set),logout 时把 jti 加入,代理验签后检查。
- 黑名单按 Token 自然过期自动清理(与 CAPTCHA store 同模式)。
### P1-2 反向代理无下游超时,可被慢响应 DoS
- **位置**:[main.cpp:1633-1639](../src/main.cpp#L1633)
- **问题**:`client.get_stream_sync(url, callback)` / `post_stream_sync` 未传超时。`HttpClient` 的超时行为取决于 libhttp 实现,若下游(static:9200 / dnfs:8006)hang 住,gateway 工作线程会被永久占用。HttpServer 线程池仅 4 线程([main.cpp:540](../src/main.cpp#L540)),4 个慢请求即可让 gateway 拒绝服务。
同样问题见于 `verify_captcha_remote`、`verify_email_code_remote`、`handleCaptchaImage`——虽然传了 5s/20s 超时,但 `MsgClient::call` 短连接 + 阻塞,5 秒内 4 个慢请求即可耗尽线程池。
- **建议**:
- 代理调用显式设置超时(如 10s)。
- HttpServer 线程池调大或改用异步模型。
- 关键路径下游调用加熔断(连续超时降级)。
### P1-3 代理透传 Cookie 与 Target-Url 到下游
- **位置**:[main.cpp:1605-1613](../src/main.cpp#L1605)、[main.cpp:1653](../src/main.cpp#L1653) `is_special_header`
- **问题**:`handleProxy` 复制「几乎所有」请求头到下游,`is_special_header` 只过滤了 `Host/connection/Accept-Encoding/XFF/X-Real-IP/Forwarded`,**未过滤 `Cookie` 和 `Target-Url`**。
后果:
- 用户的 `q4_token` Cookie 被透传到 static/dnfs。若下游有 access log 记录请求头,Token 泄漏。
- `Target-Url` 头透传到下游,可能被下游误解或记录(信息泄漏)。
虽然后续 `if (!cookie_token.empty())` 会用 `Authorization: Bearer` 覆盖客户端的 Authorization,但 Cookie 头不会被覆盖。
- **建议**:`is_special_header` 增加 `Cookie`、`Target-Url`(自定义头不应透传)。
### P1-4 无请求体大小限制(模式 B 下尤其严重)
- **位置**:[main.cpp:787](../src/main.cpp#L787)、[1308](../src/main.cpp#L1308)、[1405](../src/main.cpp#L1405)
- **问题**:直接 `json::parse(req.body())`,无大小检查。模式 A 下 WAF 有 `SecRequestBodyLimit 10MB` 兜底;模式 B 下无任何限制,客户端发 1GB JSON body 即可让 gateway OOM。
- **建议**:
- 在每个 handler 入口检查 `req.body().size()`,超限返回 413。
- 或在 libhttp 层统一限制。
### P1-5 代理鉴权先于白名单检查,泄漏白名单信息
- **位置**:[main.cpp:1538-1591](../src/main.cpp#L1538)
- **问题**:流程为:先验签 → 检查过期 → 再检查 Target-Url 是否在 `_OPEN_URL`。持有有效 Token 的攻击者可枚举 Target-Url:
- 返回 401 → Token 无效或已过期(无法区分)
- 返回 403 → Token 有效但 Target-Url 不在白名单
- 返回 200/其他 → Target-Url 在白名单内
从而探测出 `Proxy.opened` 的完整内容(配置敏感信息)。
- **建议**:先检查白名单,白名单命中后再做鉴权。
### P1-6 LocalTime 用本地时间而非 UTC,跨时区/迁移出问题
- **位置**:[LocalTime.hpp:14-18](../inc/LocalTime.hpp#L14)
- **问题**:`getCurrent` 用 `current_zone()`,Token payload 的 `iat`/`created_at` 都是本地时间字符串。问题:
- 服务器迁移时区(如 CST→UTC),旧 Token 的过期判断会错(差 8 小时)。
- DST 切换瞬间(夏令时)有过期判断偏差。
- `parse` 与 `getCurrent` 必须在同一时区,跨服务器部署不一致。
LogC 用 `_utc_now_iso()` UTC,与 LocalTime 不一致——同一进程两套时间表示。
- **建议**:Token payload 统一用 UTC ISO8601(可复用 LogC 的 `_utc_now_iso` 逻辑)。
---
## P2 中(建议修复)
### P2-1 RSA 密钥生成失败未在启动时检查
- **位置**:[main.cpp:542](../src/main.cpp#L542)
- **问题**:`_KEYGEN = std::make_unique<MyRSA::Generator>()` 后未验证密钥是否生成成功。`Generator` 构造函数中 `generate_rsa_key` 失败时返回 nullptr(见 [MyRSA.hpp:88-112](../../libcrypt/inc/us/MyRSA.hpp#L88)),后续 `get_private_key_pem()` 会返回空字符串,`Private_Key::from_pem("")` 抛异常,被 wrapperHttp 兜底成 500,但 gateway 已经在跑,所有登录都会 500。
- **建议**:`main()` 中启动后做一次自签自验(`_PRIV.Sign("test")` + `_PUBL.Verify`),失败直接退出。
### P2-2 is_safe_target 未覆盖双重编码与反斜杠
- **位置**:[main.cpp:1688](../src/main.cpp#L1688)
- **问题**:检查了 `..`、`%2e`、`%2f`、`%5c`,但未检查:
- 双重编码 `%252e%252e`(下游若做二次 URL 解码会变 `..`)。
- 反斜杠 `\`(Windows 路径分隔符,Linux 影响小但应防御)。
- 非 ASCII Unicode 点(`。` 等,极少见)。
- **建议**:增加 `%25` 检查或白名单字符集(只允许 `[A-Za-z0-9/._~-]`)。
### P2-3 url 拼接产生双斜杠
- **位置**:[main.cpp:1518-1520](../src/main.cpp#L1518)
```cpp
url = t_url; // "http://127.0.0.1:9200"
url += "/";
url += target_path; // "/index.html"
// 结果: "http://127.0.0.1:9200//index.html"
```
- **问题**:双斜杠可能导致下游服务(如 Nginx `merge_slashes off`)解析异常。
- **建议**:`target_path` 已确认以 `/` 开头,无需再补 `/`。
### P2-4 refresh_token 旧 Token 仍可用
- **位置**:[main.cpp:1099](../src/main.cpp#L1099)
- **问题**:刷新后旧 Token 未作废(因无 jti 黑名单,见 P1-1)。攻击者拿到旧 Token 仍可:
- 用旧 Token 访问代理(在 keep_time 内)。
- 用旧 Token 再次刷新(消耗主用户的刷新次数)。
### P2-5 限流 cleanup 性能与内存
- **位置**:[main.cpp:213-219](../src/main.cpp#L213)
- **问题**:cleanup 在 `size() > 10000` 时触发,遍历全表 O(n)。若攻击者用 10001 个不同 IP 触发,每次请求都遍历 1 万条,放大 CPU 消耗。
- **建议**:改用 LRU 或定期后台线程清理。
### P2-6 signalHandler 调用非 async-signal-safe 函数
- **位置**:[main.cpp:445-449](../src/main.cpp#L445)
- **问题**:`signalHandler` 调用 `std::cout`、`_SERVE->stop()`(可能用锁),在信号上下文中是 UB。实践中常能工作,但严格来说应改用 `sigwait` + 独立线程或 `signalfd`。
### P2-7 配置项无范围校验
- **位置**:[main.cpp:507-517](../src/main.cpp#L507)
- **问题**:读取的 `_PORT`、`_TOKEN_KEEP_TIME`、`_TOKEN_REFRESH_GRACE_MULTIPLIER`、`_TOKEN_MAX_REFRESH` 均无范围检查。如 `_TOKEN_KEEP_TIME = 999999999` 会让 Token 几乎不过期;`_PORT = -1` 行为异常。
- **建议**:启动时校验合理范围,超限拒绝启动。
---
## P3 低(信息性建议)
### P3-1 Cookie 未用 `__Host-` 前缀
- **位置**:[main.cpp:928-932](../src/main.cpp#L928)
- **问题**:Cookie 名 `q4_token`,建议改 `__Host-q4_token` 绑定 hostname + Path=/ + Secure,防止子域 Cookie 注入(若未来有子域服务)。
### P3-2 handleProxy 对默认目标仍透传客户端 Authorization
- **位置**:[main.cpp:1572-1575](../src/main.cpp#L1572)
- **问题**:公开目标(flag=true)走 else 分支,只清空 `cookie_token`。但客户端发的 `Authorization` 头已在 [1605-1613](../src/main.cpp#L1605) 被复制到下游请求头中,未被覆盖(因为 `cookie_token.empty()`)。static 可能不解析,但仍是信息泄漏。
### P3-3 RSA 密钥对进程内存生成,无法跨实例共享
- **位置**:[main.cpp:542](../src/main.cpp#L542)
- **问题**:每次启动重新生成。多实例部署时,Token 在不同实例间不通用(用户登录 gateway-A 后,请求转到 gateway-B 会验签失败)。当前单实例部署,问题不大;若未来水平扩展,需改为持久化密钥(加密存储)。
### P3-4 whoami 返回 Token 内部字段
- **位置**:[main.cpp:1067-1076](../src/main.cpp#L1067)
- **问题**:返回 `iat`/`created_at`/`keep_time`,给攻击者提供精确过期时间。便于攻击者规划 Token 重放,但风险有限。
### P3-5 OPTIONS 预检未显式注册
- **位置**:[registerRoutes](../src/main.cpp#L610)
- **问题**:没有 `server.options("*", ...)`,README 说「通配 * 返回 CORS 处」,但代码依赖 `server.get("*")` 捕获 OPTIONS。若 libhttp 的 HttpServer 不自动路由 OPTIONS 到 GET,OPTIONS 会被当成 404 或走 proxy。建议显式注册 OPTIONS handler。
---
## 修复优先级建议
| 优先级 | 项目 | 工作量 | 建议时间 |
|--------|------|--------|----------|
| ✅ 已修复 | P0-1 邮件轰炸防护(v0.5.4) | 中 | 2026-08-01 完成 |
| 立即 | P0-2 CAPTCHA 强制化 | 小 | 本周 |
| 立即 | P0-3 模式 B 下 IP 来源修正 | 小 | 本周 |
| 立即 | P0-4 PBKDF2/RAND_bytes 返回值检查 | 极小 | 本周 |
| 短期 | P1-1 Token 黑名单(jti) | 中 | 2 周内 |
| 短期 | P1-2 代理下游超时 + 线程池调大 | 中(需改 libhttp) | 2 周内 |
| 短期 | P1-3 过滤 Cookie/Target-Url 头 | 极小 | 2 周内 |
| 短期 | P1-4 请求体大小限制 | 小 | 2 周内 |
| 短期 | P1-5 代理鉴权顺序调整 | 小 | 2 周内 |
| 中期 | P1-6 Token 时间统一 UTC | 小(需处理存量 Token) | 1 月内 |
| 中期 | P2-* 各项 | 小-中 | 1 月内 |
---
## 已有的良好实践(肯定)
为公正起见,记录审计中确认的已有安全设计:
| 特性 | 实现位置 | 说明 |
|------|----------|------|
| 密码哈希 | [main.cpp:58](../src/main.cpp#L58) `PBKDF2Hasher` | PBKDF2-HMAC-SHA256,10 万轮 + 16 字节随机盐,行业标准 |
| 常数时间比较 | [main.cpp:107-111](../src/main.cpp#L107) | `diff |= hash[i] ^ expected[i]` 防 timing attack |
| 防用户名枚举 | [main.cpp:862-868](../src/main.cpp#L862) | 用户不存在时执行 dummy PBKDF2 拉平响应时序 |
| 源码路径屏蔽 | [ExpBase.hpp:173-197](../inc/ExpBase.hpp#L173) | `what()` 注释了位置信息,不进入 HTTP 响应 |
| 异常兜底 | [main.cpp:703-724](../src/main.cpp#L703) | `wrapperHttp` 外层 catch 统一返回 "Internal Server Error" |
| 路径规范化 | [main.cpp:1688](../src/main.cpp#L1688) `is_safe_target` | 防路径遍历/协议注入/空字节/URL 编码绕过 |
| 可伪造头剥离 | [main.cpp:1653](../src/main.cpp#L1653) `is_special_header` | 剥离客户端 XFF/X-Real-IP/Forwarded,由 gateway 主动设置可信值 |
| RSA 填充 | [MyRSA.hpp:211](../../libcrypt/inc/us/MyRSA.hpp#L211) | 默认 PSS 填充(比 PKCS1_v1_5 更安全) |
| RSA 密钥长度 | [MyRSA.hpp:85](../../libcrypt/inc/us/MyRSA.hpp#L85) | 2048 位,符合当前推荐 |
| Cookie 安全属性 | [main.cpp:928-932](../src/main.cpp#L928) | `HttpOnly + Secure + SameSite=Lax` |
| 密码强度校验 | [main.cpp:148](../src/main.cpp#L148) `PasswordValidator` | 8+ 字符、4 类中至少 3 类 |
| 限流清理 | [main.cpp:213-219](../src/main.cpp#L213) | 超过 1 万条自动清理(虽性能可优化,见 P2-5) |
| 集中式日志 | [LogC.hpp](../inc/LogC.hpp) | 异步批量 + fail-safe,不拖慢业务 |
| Token 刷新机制 | [main.cpp:1099](../src/main.cpp#L1099) | 保留 iat 限制总生命周期,refresh_count 上限 |
---
## 附录:审计检查清单
| 类别 | 检查项 | 结果 |
|------|--------|------|
| 认证 | 密码哈希算法 | ✅ PBKDF2 |
| 认证 | 密码哈希参数(轮数/盐长度) | ✅ 10 万轮 / 16 字节 |
| 认证 | 常数时间比较 | ✅ |
| 认证 | 防用户名枚举 | ✅ dummy PBKDF2 |
| 认证 | Token 签名算法 | ✅ RSA-PSS + SHA256 |
| 认证 | Token 过期校验 | ✅ |
| 认证 | Token 撤销机制 | ❌ 见 P1-1 |
| 认证 | CAPTCHA 强制 | ✅ login + send_code 强制,register 可选(见 P0-2) |
| 授权 | 反向代理白名单 | ✅ |
| 授权 | 鉴权顺序 | ❌ 见 P1-5 |
| 授权 | is_admin 字段 | ✅ 由 gateway 统一签发,下游不各自维护 |
| 限流 | 登录/注册/发码/刷新 | ✅ 均有限流 |
| 限流 | 邮箱级限流 | ✅ 3 次/小时 + 10 次/天(gateway + smtp 双层,见 P0-1 修复) |
| 限流 | IP 来源可信 | ❌ 模式 B 下可伪造,见 P0-3 |
| 限流 | cleanup 性能 | ⚠️ 见 P2-5 |
| 输入校验 | 请求体大小 | ❌ 见 P1-4 |
| 输入校验 | JSON 解析异常处理 | ✅ |
| 输入校验 | 路径规范化 | ✅(可加强,见 P2-2) |
| 输入校验 | 邮箱格式 | ✅ 正则 |
| 输入校验 | 密码强度 | ✅ |
| 输出 | 错误信息泄漏 | ✅ what() 屏蔽 + 兜底 |
| 输出 | Token 内部字段 | ⚠️ 见 P3-4 |
| 传输 | TLS 终止位置 | ✅ WAF 模式下由 WAF 终止 |
| 传输 | Cookie 安全属性 | ✅ HttpOnly+Secure+SameSite |
| 传输 | HSTS | ✅ 由 WAF 注入 |
| 并发 | 限流器线程安全 | ✅ mutex |
| 并发 | CAPTCHA store 线程安全 | ✅ mutex |
| 并发 | 信号处理 | ⚠️ 见 P2-6 |
| 依赖 | OpenSSL 返回值检查 | ❌ 见 P0-4 |
| 依赖 | libcurl 超时 | ❌ 见 P1-2 |
| 配置 | 配置项范围校验 | ❌ 见 P2-7 |
| 配置 | RSA 密钥生成自检 | ❌ 见 P2-1 |
| 日志 | 集中式日志 | ✅ |
| 日志 | fail-safe | ✅ |
| 日志 | 敏感信息记录 | ⚠️ Token 不入日志,但 IP/用户名记录(可接受) |
---
*报告结束*
+29 -2
View File
@@ -28,7 +28,7 @@ using namespace qing;
namespace json = boost::json; namespace json = boost::json;
namespace po = boost::program_options; namespace po = boost::program_options;
constexpr const char *_VER = "0.5.3"; constexpr const char *_VER = "0.5.4";
////////////////////////////////// //////////////////////////////////
#include <sstream> #include <sstream>
@@ -345,8 +345,13 @@ static std::unique_ptr<MyRSA::Generator> _KEYGEN;
//static std::unique_ptr<Sqlite3> _SQLDB; //static std::unique_ptr<Sqlite3> _SQLDB;
static RateLimiter _LOGIN_RATE; static RateLimiter _LOGIN_RATE;
static RateLimiter _REGISTER_RATE; static RateLimiter _REGISTER_RATE;
static RateLimiter _EMAIL_CODE_RATE; // 发送邮箱验证码限流 static RateLimiter _EMAIL_CODE_RATE; // 发送邮箱验证码 IP 级限流
static RateLimiter _REFRESH_TOKEN_RATE; // Token 刷新限流 static RateLimiter _REFRESH_TOKEN_RATE; // Token 刷新限流
// 邮箱级双窗口限流:同一邮箱在 gateway 层的发送配额。
// 配合 IP 限流(IP 可被代理池绕过)与 smtp 同邮箱 60s 冷却(仅防短期重复)。
// 双窗口设计:小时窗限制短期轰炸,天窗限制长期骚扰。
static RateLimiter _EMAIL_ADDR_RATE_HOUR; // 3 次/小时/邮箱
static RateLimiter _EMAIL_ADDR_RATE_DAY; // 10 次/天/邮箱
/* pymsg 下游调用超时(秒): /* pymsg 下游调用超时(秒):
* captcha / smtp verify_email 均为本机内存操作,5s 足够; * captcha / smtp verify_email 均为本机内存操作,5s 足够;
@@ -1421,6 +1426,28 @@ http::response<http::string_body> handleSendEmailCode(
if (!std::regex_match(email, email_regex)) if (!std::regex_match(email, email_regex))
THROW_EX_CUSTOM(HttpException, 3004, "Invalid email format", 400, "/register/send_code"); THROW_EX_CUSTOM(HttpException, 3004, "Invalid email format", 400, "/register/send_code");
// CAPTCHA 强制校验(防邮件轰炸:挡住自动化脚本,见审计报告 P0-1)
// captcha_token / captcha_answer 必填,经 captcha:8009 pymsg 校验,Token 一次性消费。
// fail-closed:captcha 服务不可用或响应异常一律视为校验失败。
auto captcha_token_it = obj.find("captcha_token");
auto captcha_answer_it = obj.find("captcha_answer");
if (captcha_token_it == obj.end() || captcha_answer_it == obj.end())
THROW_EX_CUSTOM(HttpException, 3007, "CAPTCHA required", 400, "/register/send_code");
if (!verify_captcha_remote(std::string(captcha_token_it->value().as_string()),
std::string(captcha_answer_it->value().as_string())))
THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA", 400, "/register/send_code");
// 邮箱级双窗口限流(CAPTCHA 通过后才计入配额,避免无效请求消耗限流额度)
// 3 次/小时 + 10 次/天;IP 限流可被代理池绕过,邮箱维度是更可靠的限制。
if (!_EMAIL_ADDR_RATE_HOUR.check_and_record(email, 3, 3600))
THROW_EX_CUSTOM(HttpException, 3104,
"Too many code requests for this email. Please try again later.",
429, "/register/send_code");
if (!_EMAIL_ADDR_RATE_DAY.check_and_record(email, 10, 86400))
THROW_EX_CUSTOM(HttpException, 3105,
"Daily email limit exceeded. Please try again tomorrow.",
429, "/register/send_code");
// 调 smtp 服务(pymsg/TCP :8008)发送验证码 // 调 smtp 服务(pymsg/TCP :8008)发送验证码
bool sent = false; bool sent = false;
int retry_after = 0; // smtp 同邮箱发送冷却的剩余秒数,0 表示非冷却拒绝 int retry_after = 0; // smtp 同邮箱发送冷却的剩余秒数,0 表示非冷却拒绝
+52
View File
@@ -73,6 +73,13 @@ CODE_LEN = 6
CODE_TTL = 600 # 验证码有效期(秒) CODE_TTL = 600 # 验证码有效期(秒)
SEND_INTERVAL = 60 # 同一邮箱两次成功发送的最小间隔(秒) SEND_INTERVAL = 60 # 同一邮箱两次成功发送的最小间隔(秒)
MAX_VERIFY_ATTEMPTS = 5 # 同一验证码最大校验失败次数,达到即作废(防枚举 6 位数字码) MAX_VERIFY_ATTEMPTS = 5 # 同一验证码最大校验失败次数,达到即作废(防枚举 6 位数字码)
# ── 邮件轰炸防护(兜底:gateway 层已有 CAPTCHA + 邮箱级限流,此处为最后防线) ──
# 邮箱级双窗口:与 gateway 对齐,但作为 gateway 被绕过/重启时的兜底
EMAIL_HOURLY_LIMIT = int(os.environ.get("EMAIL_HOURLY_LIMIT", "3")) # 单邮箱 3 次/小时
EMAIL_DAILY_LIMIT = int(os.environ.get("EMAIL_DAILY_LIMIT", "10")) # 单邮箱 10 次/天
# 全局熔断:防止 smtp 账号被服务商封禁;正常注册流量通常 <10/小时,默认值已是 10 倍异常
SMTP_HOURLY_LIMIT = int(os.environ.get("SMTP_HOURLY_LIMIT", "100")) # 全局 100 封/小时
# 多数邮箱服务商(163/QQ 等)强制 From 头与登录账号一致,故默认跟随 SMTP_USER; # 多数邮箱服务商(163/QQ 等)强制 From 头与登录账号一致,故默认跟随 SMTP_USER;
# 仅当服务商允许自定义发件身份时才在 .env 里显式配 MAIL_FROM 覆盖 # 仅当服务商允许自定义发件身份时才在 .env 里显式配 MAIL_FROM 覆盖
MAIL_FROM = os.environ.get("MAIL_FROM") or SMTP_USER MAIL_FROM = os.environ.get("MAIL_FROM") or SMTP_USER
@@ -82,6 +89,14 @@ DEFAULT_CONTENT = f"DNFS: 您的邮箱验证码为 {{code}},{CODE_TTL // 60}
code_store = {} # email -> {'code': str, 'expires_at': float, 'attempts': int} code_store = {} # email -> {'code': str, 'expires_at': float, 'attempts': int}
send_history = {} # email -> 上次成功发送的时间戳(发送冷却用) send_history = {} # email -> 上次成功发送的时间戳(发送冷却用)
# 邮件轰炸防护:邮箱级长期限流与全局熔断的发送日志。
# send_log[email] = {'hour': [ts,...], 'day': [ts,...]} 仅记录成功发送的时间戳。
# global_send_log = [ts,...] 全局成功发送时间戳(小时窗口)。
# 惰性清理:每次检查时剔除已滑出窗口的时间戳,避免内存无限增长。
send_log = {} # email -> {'hour': [ts], 'day': [ts]}
global_send_log = [] # [ts] 全局小时窗口发送时间戳
STORE_LOCK = threading.Lock() STORE_LOCK = threading.Lock()
@@ -155,6 +170,34 @@ class SmtpServer:
# 发送失败时在下方回滚,"失败不计入冷却"语义不变 # 发送失败时在下方回滚,"失败不计入冷却"语义不变
send_history[email] = time.time() send_history[email] = time.time()
# 邮件轰炸防护:邮箱级长期限流 + 全局熔断(预占模式,与 60s 冷却一致)
# gateway 层已有 CAPTCHA + 邮箱级限流,此处为 gateway 被绕过/重启时的兜底。
# 预占:检查时立即记录时间戳;发送失败时回滚,"失败不计入配额"语义不变。
now = time.time()
with STORE_LOCK:
log = send_log.setdefault(email, {'hour': [], 'day': []})
# 清理已滑出窗口的时间戳(惰性清理,避免内存无限增长)
log['hour'] = [t for t in log['hour'] if now - t < 3600]
log['day'] = [t for t in log['day'] if now - t < 86400]
global_send_log[:] = [t for t in global_send_log if now - t < 3600]
# 邮箱级配额检查
if len(log['hour']) >= EMAIL_HOURLY_LIMIT:
return {"sent": False,
"error": f"该邮箱每小时发送上限({EMAIL_HOURLY_LIMIT}次)已达,请稍后重试"}
if len(log['day']) >= EMAIL_DAILY_LIMIT:
return {"sent": False,
"error": f"该邮箱每日发送上限({EMAIL_DAILY_LIMIT}次)已达,请明日重试"}
# 全局熔断:防 smtp 账号被服务商封禁
if len(global_send_log) >= SMTP_HOURLY_LIMIT:
logs.error("smtp", f"全局熔断:近 1 小时已发送 {len(global_send_log)} 封,"
f"达到上限 {SMTP_HOURLY_LIMIT},拒绝发送给 {email}")
return {"sent": False,
"error": "邮件服务发送量已达上限,请稍后重试"}
# 预占(发送失败时在下方回滚)
log['hour'].append(now)
log['day'].append(now)
global_send_log.append(now)
code = "".join(str(random.randint(0, 9)) for _ in range(CODE_LEN)) code = "".join(str(random.randint(0, 9)) for _ in range(CODE_LEN))
subject = str(req.get("subject") or DEFAULT_SUBJECT) subject = str(req.get("subject") or DEFAULT_SUBJECT)
content = str(req.get("content") or DEFAULT_CONTENT).replace("{code}", code) content = str(req.get("content") or DEFAULT_CONTENT).replace("{code}", code)
@@ -184,6 +227,15 @@ class SmtpServer:
with STORE_LOCK: with STORE_LOCK:
code_store.pop(email, None) code_store.pop(email, None)
send_history.pop(email, None) # 回滚冷却占位,允许立即重试 send_history.pop(email, None) # 回滚冷却占位,允许立即重试
# 回滚限流预占(移除刚才追加的时间戳)
log = send_log.get(email)
if log:
if log['hour'] and log['hour'][-1] == now:
log['hour'].pop()
if log['day'] and log['day'][-1] == now:
log['day'].pop()
if global_send_log and global_send_log[-1] == now:
global_send_log.pop()
logs.error("smtp", f"{email}: 验证码发送失败({err})") logs.error("smtp", f"{email}: 验证码发送失败({err})")
return {"sent": False, "error": f"验证码发送失败:{err}"} return {"sent": False, "error": f"验证码发送失败:{err}"}