Files
blackbird/docs/security/smtp_SECURITY_AUDIT_20260801.md
T
qing 29e4f96061 docs: 合并 smtp 安全审计复审(0809)进初版(0801)
- 将 0809 复审新增的 7 项发现(P1-F1/P2-F2/P2-F3/P3-F4~F7)及测试盲区章节
  并入 smtp_SECURITY_AUDIT_20260801.md,形成统一合并版(保留初版验证记录与
  已修复结论)
- 重复的 0809 复审文档(本地未被 git 跟踪)已删除
- docs/INDEX.md smtp 行指回合并版并标注'初版+复审合并版'
2026-08-09 12:03:36 +08:00

352 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**:纳入整体内网加固与高可用规划
- [ ] 验证码存储考虑持久化(可选,当前内存实现适合单实例)