From 49f0698b9d801be87b4f49d3fe092598c0589516 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sat, 1 Aug 2026 02:37:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(gateway,smtp):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E9=82=AE=E4=BB=B6=E8=BD=B0=E7=82=B8=E6=BC=8F=E6=B4=9E(?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=20P0-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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)→ 全局熔断 --- gateway/README.md | 25 +- gateway/docs/SECURITY_AUDIT_20260801.md | 323 ++++++++++++++++++++++++ gateway/src/main.cpp | 31 ++- smtp/smtp_v2.py | 52 ++++ 4 files changed, 424 insertions(+), 7 deletions(-) create mode 100644 gateway/docs/SECURITY_AUDIT_20260801.md diff --git a/gateway/README.md b/gateway/README.md index 66b643d..b25dea0 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -10,7 +10,8 @@ UniServe Framework 的应用层网关,C++20 实现。 - **反向代理**:携带 Token 的请求校验后转发到 `Proxy.opened` 中放开的内部服务。 - **CAPTCHA 接口**:`/captcha` 生成验证码 token + 文本(内存存储,5 分钟过期,一次性使用)。 - **邮箱验证码**:`/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 头,允许跨域。 ## 安全特性 @@ -86,11 +87,24 @@ UniServe Framework 的应用层网关,C++20 实现。 向目标邮箱发送注册验证码(经 pymsg 调 smtp:8008 `send_email`,10 分钟有效)。 +**请求体**(CAPTCHA 必填,防邮件轰炸): + ```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 *`(反向代理) @@ -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.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,与其他步骤一致)。 diff --git a/gateway/docs/SECURITY_AUDIT_20260801.md b/gateway/docs/SECURITY_AUDIT_20260801.md new file mode 100644 index 0000000..dde39a9 --- /dev/null +++ b/gateway/docs/SECURITY_AUDIT_20260801.md @@ -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()` 后未验证密钥是否生成成功。`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/用户名记录(可接受) | + +--- + +*报告结束* diff --git a/gateway/src/main.cpp b/gateway/src/main.cpp index a4ac7cf..cbd0262 100644 --- a/gateway/src/main.cpp +++ b/gateway/src/main.cpp @@ -28,7 +28,7 @@ using namespace qing; namespace json = boost::json; namespace po = boost::program_options; -constexpr const char *_VER = "0.5.3"; +constexpr const char *_VER = "0.5.4"; ////////////////////////////////// #include @@ -345,8 +345,13 @@ static std::unique_ptr _KEYGEN; //static std::unique_ptr _SQLDB; static RateLimiter _LOGIN_RATE; static RateLimiter _REGISTER_RATE; -static RateLimiter _EMAIL_CODE_RATE; // 发送邮箱验证码限流 +static RateLimiter _EMAIL_CODE_RATE; // 发送邮箱验证码 IP 级限流 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 下游调用超时(秒): * captcha / smtp verify_email 均为本机内存操作,5s 足够; @@ -1421,6 +1426,28 @@ http::response handleSendEmailCode( if (!std::regex_match(email, email_regex)) 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)发送验证码 bool sent = false; int retry_after = 0; // smtp 同邮箱发送冷却的剩余秒数,0 表示非冷却拒绝 diff --git a/smtp/smtp_v2.py b/smtp/smtp_v2.py index 8255075..a9b2f54 100644 --- a/smtp/smtp_v2.py +++ b/smtp/smtp_v2.py @@ -73,6 +73,13 @@ CODE_LEN = 6 CODE_TTL = 600 # 验证码有效期(秒) SEND_INTERVAL = 60 # 同一邮箱两次成功发送的最小间隔(秒) 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; # 仅当服务商允许自定义发件身份时才在 .env 里显式配 MAIL_FROM 覆盖 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} 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() @@ -155,6 +170,34 @@ class SmtpServer: # 发送失败时在下方回滚,"失败不计入冷却"语义不变 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)) subject = str(req.get("subject") or DEFAULT_SUBJECT) content = str(req.get("content") or DEFAULT_CONTENT).replace("{code}", code) @@ -184,6 +227,15 @@ class SmtpServer: with STORE_LOCK: code_store.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})") return {"sent": False, "error": f"验证码发送失败:{err}"}