# smtp 模块安全审计报告(合并版) **初版审计日期**: 2026-08-01 **复审日期**: 2026-08-09 **审计范围**: `smtp.py`(旧版,已下线)、`smtp_v2.py`、`log_client.py`、`smtp/README.md`、`tests/blackbox/test_smtp.py` **代码版本**: `main` @ `63cfdd2`(fast-forward 自 `23a8559`) **审计方式**: 静态代码审查 + 与 gateway 集成路径回溯 **合并说明**: 本文件为 2026-08-01 初版与 2026-08-09 复审的合并版。初版结论中仍成立的条目保留,复审新增的 7 项发现(P1-F1、P2-F2、P2-F3、P3-F4~F7)并入对应等级章节。原复审文档 `smtp_SECURITY_AUDIT_20260809.md` 已并入本文件并删除。 --- ## 一、结论速览 | 等级 | 初版(0801) | 复审新增(0809) | 当前合计 | 已修复 | 未修复 | |------|-----------|---------------|---------|--------|--------| | P0 | 2 | 0 | 2 | 1 | 1 | | P1 | 2 | 1 | 3 | 1 | 2 | | P2 | 0 | 2 | 2 | 0 | 2 | | P3 | 0 | 4 | 4 | 0 | 4 | 复审**新发现 7 项**,其中最关键的是 **P1-F1:`now` 变量遮蔽导致 SMTP 发送失败时限流/熔断回滚失效**——一个文档已声明"失败不计入配额"但实际未生效的**失效安全控制**,可被用于配额耗尽型拒绝服务。 > 初版审计 **未验证** `send_email` 失败路径下的限流/熔断回滚,亦未覆盖 OTP 随机源、日志打印验证码等问题——这正是复审新增发现所在。 --- ## 二、代码验证记录 **初版验证日期**: 2026-08-03 **初版验证方式**: 逐条对照初版审计与当时代码(`smtp_v2.py`、`smtp.py`、`log_client.py`、`_run.sh`) ### 初版验证结果汇总(0803) | 等级 | 总数 | 已修复 | 未修复 | 修复率 | |------|------|--------|--------|--------| | P0 | 2 | 1 | 1 | 50% | | P1 | 2 | 1 | 1 | 50% | ### 复审状态(0809) | 编号 | 初版结论 | 复审复核 | 说明 | |------|--------|---------|------| | P0-1 | 旧 `smtp.py` 已删除下线 ✅ | 维持已修复 | `_run.sh` 启动 `smtp_v2.py`,无旧版残留风险 | | P0-2 | pymsg 无认证,依赖 127.0.0.1 隔离 ❌ | **维持未修复** | 与所有 pymsg 服务同源,风险由网络隔离承担(见 §九) | | P1-1 | 验证码存内存,重启失效 ❌ | **维持未修复** | 单实例设计下的可用性弱点(见 §九) | | P1-2 | `SMTP_HOURLY_LIMIT` 生效 ✅ | 维持已修复 | 默认值 100/h 正确加载 | | P1-F1 | —(初版未覆盖) | **新增未修复** | `now` 遮蔽导致失败回滚失效(见 §五) | | P2-F2 | —(初版未覆盖) | **新增未修复** | 验证码明文入 Logs 表(见 §六) | | P2-F3 | —(初版未覆盖) | **新增未修复** | `random` 弱随机源(见 §六) | | P3-F4~F7 | —(初版未覆盖) | **新增未修复** | 见 §七 | --- ## 三、修复进度 | 优先级 | 总数 | 已修复 | 待修复 | |--------|------|--------|--------| | P0 | 2 | 1 | 1 | | P1 | 3 | 1 | 2 | | P2 | 2 | 0 | 2 | | P3 | 4 | 0 | 4 | --- ## 四、🔴 P0 — 严重问题 ### P0-1: 旧版 smtp.py 存在大量致命漏洞(已立即下线) ✅ 已修复 - **位置**: ~~`smtp.py`~~、`_run.sh` - **问题**: 旧版 smtp.py 缺乏关键防护: 1. 无发送速率限制(可被用于邮件炸弹) 2. 无同邮箱 60 秒发送冷却 3. 无验证码过期清理机制 4. 无连续失败锁定机制 5. 存在 `NameError` 风险:`server.quit()` 在 finally 块中,若 SMTP 连接失败导致 `server` 未赋值则崩溃 - **已确认**: `_run.sh` L6 使用 `exec python3 smtp_v2.py`,生产环境已切换至新版 - **修复 (2026-08-04)**: 旧版 `smtp.py` 已**删除下线**,杜绝误启动;README 中相关描述已同步更新 ### P0-2: 依赖 pymsg 无认证(缓解:绑定 127.0.0.1) - **位置**: `smtp_v2.py` L113-117 - **说明**: 使用 pymsg RpcServer,无认证机制(与 pymsg P0-3 同源),依赖 127.0.0.1 网络隔离。当前风险由网络隔离承担,建议在 `SECURITY_HARDENING_PLAN` 的内网 mTLS/共享密钥方案中统一解决。 --- ## 五、🟠 P1 — 高危问题 | # | 问题 | 状态 | 说明 | |---|------|------|------| | P1-1 | 验证码存储在进程内存,服务重启后所有未使用验证码失效 | ❌ 未修复 | 可被利用造成拒绝服务(频繁重启导致用户无法注册) | | P1-2 | 全局发送熔断配置生效(`SMTP_HOURLY_LIMIT`) | ✅ 已修复 | smtp_v2 已有且环境变量正确加载(默认 100/h) | | P1-F1 | `now` 变量遮蔽,发送失败时限流/熔断回滚失效 | ❌ 未修复 | 见下详述 | ### P1-1: 验证码存进程内存(详情) - **位置**: `smtp_v2.py` L90 - 验证码存储在进程内存 `code_store` dict,服务重启后全部失效。单实例设计下可接受;若需多实例/高可用应引入集中式存储(Redis 等)并配失效通知。 ### P1-F1: `now` 变量遮蔽,导致 SMTP 发送失败时的限流/熔断回滚失效 - **位置**: `smtp_v2.py` `L176`、`L210`、`L233`–`L238` - **性质**: 失效的安全控制(配额耗尽型可用性风险) - **严重程度**: P1(高) **问题描述** `_handle_send_email` 在邮箱级限流预占阶段记录时间戳: ```python # L176 now = time.time() ... # L177-199(在 with STORE_LOCK 内) log['hour'].append(now) # L197 log['day'].append(now) # L198 global_send_log.append(now) # L199 ``` 随后在写入验证码的锁块内**重新赋值**了同名变量: ```python # L205-213(另一段 with STORE_LOCK) code_store[email] = {...} now = time.time() # L210 ← 覆盖了 L176 的 now expired = [k for k, v in code_store.items() if v['expires_at'] < now] ``` 而在 SMTP 发送失败的回滚分支中,比较使用的是 **L210 之后的 `now`**: ```python # L227-238(except 块内,with STORE_LOCK) log = send_log.get(email) if log: if log['hour'] and log['hour'][-1] == now: # L233 ← now 已是 L210 的值 log['hour'].pop() if log['day'] and log['day'][-1] == now: # L236 log['day'].pop() if global_send_log and global_send_log[-1] == now: # L237 global_send_log.pop() ``` 由于 `time.time()` 在 L176 与 L210 之间至少相隔毫秒级,二者浮点值**永不相等**,因此 `==` 比较恒为 `False`,回滚分支**永远不执行 pop**。 **影响** 1. **邮箱级配额被失败消耗**:单次 SMTP 发送失败(网络抖动、上游服务商故障)会让该邮箱的 `3次/小时`、`10次/天` 名额白白减少——与 README 明确声明的"失败不计入配额"语义**直接矛盾**。 2. **全局熔断计数器被失败消耗**:`global_send_log`(默认 100 封/小时)也会因失败而增长。一次短暂的 SMTP 故障即可把全局计数推过阈值,触发全局熔断,**阻断全体用户的注册验证码发送长达 1 小时**。 3. **可用性 DoS 面**:若 SMTP 处于不稳定状态,正常用户与攻击流量都会"烧"掉配额,且恢复后仍需等待窗口滑动;属于外部依赖故障被放大为平台级可用性事故。 > 注:冷却回滚(`send_history.pop`,L229)使用的是独立字典、不经 `now` 比较,**该部分仍正确**;仅限流/熔断预占回滚被破坏。 **修复建议** 引入独立变量承载预占时间戳,并在过期清理处使用局部变量,避免遮蔽: ```python # —— 限流预占阶段(原 L176 / L197-199)—— preempt_ts = time.time() ... log['hour'].append(preempt_ts) log['day'].append(preempt_ts) global_send_log.append(preempt_ts) # —— 验证码写入阶段(原 L210),过期清理用局部变量,不再覆盖 preempt_ts —— cleanup_now = time.time() expired = [k for k, v in code_store.items() if v['expires_at'] < cleanup_now] # —— 失败回滚阶段(原 L233-238),与预占使用同一 preempt_ts —— if log['hour'] and log['hour'][-1] == preempt_ts: log['hour'].pop() if log['day'] and log['day'][-1] == preempt_ts: log['day'].pop() if global_send_log and global_send_log[-1] == preempt_ts: global_send_log.pop() ``` --- ## 六、🟠 P2 — 中危问题 ### P2-F2:验证码明文写入集中日志(Logs 表)造成信息泄露 - **位置**: `smtp_v2.py` `L250` - **性质**: 敏感凭据泄露 - **严重程度**: P2(中) **问题描述** ```python logs.info("smtp", f"{email}: 验证码发送成功({code})") # L250 ``` `log_client` 会把该消息经 pymsg 异步 INSERT 进 `db` 服务的 `Logs` 表(`log_client.py` L214-224)。验证码在 10 分钟有效期内以**明文**持久化在数据库,任何能读取 `db.Logs` 的角色(运维、具备数据库访问权的内部服务、被攻陷的依赖)都可在有效期内**批量收割有效验证码**,直接绕过邮箱验证。 **影响** - 绕过注册/找回密码的邮箱验证环节,危害等同验证码接口暴露。 - 违反 OTP 处理最佳实践(验证码不应以可恢复形式落盘)。 **修复建议** 日志中去除验证码明文,仅记录事件与邮箱: ```python logs.info("smtp", f"{email}: 验证码发送成功") # 去掉 ({code}) ``` 如确需可追溯,可记入验证码的哈希(如 `hashlib.sha256(code.encode()).hexdigest()[:8]`),但推荐不记录。 ### P2-F3:验证码使用非密码学安全随机源 `random` - **位置**: `smtp_v2.py` `L201` - **性质**: 弱随机源(OTP 可预测性) - **严重程度**: P2(中,纵深防御) **问题描述** ```python code = "".join(str(random.randint(0, 9)) for _ in range(CODE_LEN)) # L201 ``` `random` 基于 Mersenne Twister(MT19937),**非密码学安全** PRNG。虽然 6 位码空间本身仅 10⁶,叠加 `MAX_VERIFY_ATTEMPTS=5` 与 60s 冷却限制了在线爆破,但使用可预测 PRNG 进一步削弱了有效熵,属于不合规的口令生成方式。 **修复建议** 改用 `secrets` 模块: ```python import secrets code = "".join(secrets.choice("0123456789") for _ in range(CODE_LEN)) # 或:code = f"{secrets.randbelow(10**CODE_LEN):0{CODE_LEN}d}" ``` --- ## 七、🟡 P3 — 低危问题(纵深防御) ### P3-F4:验证码比较非常数时间 - **位置**: `smtp_v2.py` `L271` - **性质**: 理论时序侧信道 - **严重程度**: P3(低) ```python if entry and entry['code'] == code: # L271 ``` 普通 `==` 字符串比较为短路比较,理论上存在微小时序差。当前每次错误尝试都会递增 `attempts` 并在 5 次后作废,实际可利用性极低,但使用常数时间比较是 OTP 校验的规范做法。 **修复建议**: ```python import hmac if entry and hmac.compare_digest(entry['code'], code): ... ``` ### P3-F5:smtp 内部邮箱格式校验过弱 - **位置**: `smtp_v2.py` `L153` - **性质**: 输入校验(纵深防御) - **严重程度**: P3(低) ```python if not email or "@" not in email: # L153 ``` 仅检查是否含 `@`,接受 `"a@b"` 这类无 TLD 的非法地址。gateway 层已用严格正则(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)校验,smtp 仅监听 127.0.0.1,故实际风险低;但作为内部服务至少应与上游保持一致的正则,避免畸形地址被传入 `smtplib`。 ### P3-F6:调用方可控 `subject`/`content` 无长度上限 - **位置**: `smtp_v2.py` `L202`–`L203` - **性质**: 资源滥用(纵深防御) - **严重程度**: P3(低) ```python subject = str(req.get("subject") or DEFAULT_SUBJECT) content = str(req.get("content") or DEFAULT_CONTENT).replace("{code}", code) ``` `subject`/`content` 来自请求体且无长度限制。当前仅 gateway 以固定参数调用(不传这两个字段),且 smtp 仅本机可达,风险低;一旦调用方或网络边界变化,可被用于构造超大邮件载荷。建议加长度上限(如 256 / 4096 字节)。 ### P3-F7:失败响应直接回传上游 SMTP 错误原文 - **位置**: `smtp_v2.py` `L240` - **性质**: 内部信息暴露(已被 gateway 边缘掩码) - **严重程度**: P3(低) ```python return {"sent": False, "error": f"验证码发送失败:{err}"} # L240 ``` `{err}` 包含上游 SMTP 服务器返回的具体错误(如认证扩展、连接拒绝原因),属内部细节。当前 `gateway/src/main.cpp`(`handleSendEmailCode`,L2075-2083)仅在 `retry_after>0`(冷却)时透传 `smtp_err`,其余失败统一返回通用 `3103`,故 **HTTP 边缘已掩码**。但该内部细节仍会出现在 smtp 自身日志与任何直接消费 pymsg 响应的场景;若 smtp 未来被放开监听或日志被外发,即构成泄露。建议 smtp 自身返回通用文案,细节仅入日志。 --- ## 八、已验证生效的安全机制(smtp_v2.py) 以下功能经初版验证与复审静态审查确认实现正确,予以背书: 1. ✅ 同邮箱 60s 发送冷却(冷却失败回滚正确,见 §五 P1-F1 注) 2. ✅ 邮箱级限流(gateway 层双窗口:3次/小时 + 10次/天) 3. ✅ 验证码一次性消费(校验通过即删,防重放) 4. ✅ 连续失败 5 次自动作废(防 6 位码在线枚举) 5. ✅ 10 分钟有效期 + 惰性过期清理 6. ✅ 发送失败不计入冷却(防止网络错误误限制)—— *注意:冷却回滚正确,但配额/熔断回滚存在 P1-F1 缺陷* 7. ✅ 全局熔断(可选)`SMTP_HOURLY_LIMIT` 默认 100 封/小时(配置生效) 8. ✅ fail-closed:SMTP 异常时校验失败、发送失败 9. ✅ gateway 边缘对 smtp 内部错误掩码(见 P3-F7) 10. ✅ 配置缺失仅报 key 名不报 value(L127-132) --- ## 九、遗留项与接受的风险 | 编号 | 等级 | 说明 | 处置建议 | |------|------|------|---------| | P0-2 | P0 | pymsg 协议无认证,smtp 依赖 127.0.0.1 网络隔离 | 与全栈一致;建议纳入 `SECURITY_HARDENING_PLAN` 的内网 mTLS/共享密钥方案统一解决 | | P1-1 | P1 | 验证码存进程内存,服务重启即失效 | 单实例可接受;若需多实例/高可用应引入集中式存储(Redis 等)并配失效通知 | --- ## 十、测试覆盖盲区 `tests/blackbox/test_smtp.py` 仅覆盖:缺参、空 email、错误码、不存在域名正向响应。 **缺失的关键用例**(均未被覆盖,P1-F1 正处于盲区): - 同邮箱连续成功发送的 60s 冷却行为(`retry_after` 返回) - 邮箱级 `3次/小时` / `10次/天` 限流触发 - **SMTP 发送失败时配额/熔断是否被回滚**(直接命中 P1-F1 的回归测试) - 全局熔断 `SMTP_HOURLY_LIMIT` 触发与恢复 - 连续 5 次错误校验后验证码作废(`invalidated`) **建议**:补充以 `unittest.mock` 桩接 `smtplib.SMTP_SSL` 的单元测试,覆盖"发送失败→配额不变"这一回归断言,将 P1-F1 纳入防护网。 --- ## 十一、修复路线图 ### 立即(本周) - [x] 确认 smtp 服务运行的是 smtp_v2.py,旧版下线 ✅ 已确认(`_run.sh` 使用 `smtp_v2.py`,2026-08-04 删除旧版) - [x] 验证全局熔断配置生效 ✅ 已确认 - [ ] **P1-F1**:重命名限流预占时间戳变量,修复回滚(提供代码见 §五) - [ ] **P2-F2**:从日志中移除验证码明文 ### 短期 - [ ] **P2-F3**:`random` → `secrets` - [ ] **P3-F4**:`hmac.compare_digest` 比较验证码 - [ ] 补充 §十 列出的回归测试用例 ### 跟踪 - [ ] **P0-2** / **P1-1**:纳入整体内网加固与高可用规划 - [ ] 验证码存储考虑持久化(可选,当前内存实现适合单实例)