diff --git a/docs/INDEX.md b/docs/INDEX.md index 2ab79ed..8768c85 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -48,7 +48,7 @@ docs/ | static | [static_SECURITY_AUDIT_20260801.md](security/static_SECURITY_AUDIT_20260801.md) | Python 前端托管/文件服务 | | fsys | [fsys_SECURITY_AUDIT_20260808.md](security/fsys_SECURITY_AUDIT_20260808.md) | Python 文件系统服务 | | ai_review | [ai_review_SECURITY_AUDIT_20260801.md](security/ai_review_SECURITY_AUDIT_20260801.md) | Python AI 内容审核(DeepSeek + RAG) | -| smtp | [smtp_SECURITY_AUDIT_20260801.md](security/smtp_SECURITY_AUDIT_20260801.md) | Python 邮件服务(验证码发送/校验) | +| smtp | [smtp_SECURITY_AUDIT_20260801.md](security/smtp_SECURITY_AUDIT_20260801.md) | Python 邮件服务(验证码发送/校验)初版 + 0809 复审合并版 | | captcha | [captcha_SECURITY_AUDIT_20260801.md](security/captcha_SECURITY_AUDIT_20260801.md) | Python 验证码服务 | | confs | [confs_SECURITY_AUDIT_20260801.md](security/confs_SECURITY_AUDIT_20260801.md) | Python 统一配置服务 | | libmsg | [libmsg_SECURITY_AUDIT_20260806.md](security/libmsg_SECURITY_AUDIT_20260806.md) | C++ RPC 基础库(pymsg 协议帧、MsgClient/Server、RpcClient/Server) | diff --git a/docs/security/smtp_SECURITY_AUDIT_20260801.md b/docs/security/smtp_SECURITY_AUDIT_20260801.md index 3eb0857..9880d8c 100644 --- a/docs/security/smtp_SECURITY_AUDIT_20260801.md +++ b/docs/security/smtp_SECURITY_AUDIT_20260801.md @@ -1,47 +1,70 @@ -# smtp 模块安全审计报告 +# smtp 模块安全审计报告(合并版) -**审计日期**: 2026-08-01 -**审计范围**: `smtp.py`(旧版)、`smtp_v2.py`(新版)、`log_client.py` -**代码版本**: dev_qing 分支 +**初版审计日期**: 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` 已并入本文件并删除。 --- -## 代码验证记录 +## 一、结论速览 -**验证日期**: 2026-08-03 -**验证方式**: 逐条对照审计报告与当前代码(`smtp_v2.py`、`smtp.py`、`log_client.py`、`_run.sh`) +| 等级 | 初版(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 发送失败时限流/熔断回滚失效**——一个文档已声明"失败不计入配额"但实际未生效的**失效安全控制**,可被用于配额耗尽型拒绝服务。 -| 等级 | 总数 | 已修复 | 部分修复 | 未修复 | 修复率 | -|------|------|--------|----------|--------|--------| -| P0 | 2 | 1 | 0 | 1 | 50% | -| P1 | 2 | 1 | 0 | 1 | 50% | -| **合计** | **4** | **2** | **0** | **2** | **50%** | - -### 逐条验证 - -| 编号 | 状态 | 代码位置 | 验证说明 | -|------|------|----------|----------| -| P0-1 | ✅ 已修复 | ~~`smtp.py`~~ | 旧版 `smtp.py` 已删除下线(2026-08-04),杜绝误启动;`_run.sh` 使用 `smtp_v2.py` | -| P0-2 | ❌ 未修复 | `smtp_v2.py` L113-117 | 使用 pymsg RpcServer,无认证机制(与 pymsg P0-3 同源),依赖 127.0.0.1 网络隔离 | -| P1-1 | ❌ 未修复 | `smtp_v2.py` L90 | 验证码存储在进程内存 `code_store` dict,服务重启后全部失效 | -| P1-2 | ✅ 已修复 | `smtp_v2.py` L82, L191 | `SMTP_HOURLY_LIMIT` 从环境变量正确加载(默认 100),全局熔断逻辑正确生效 | +> 初版审计 **未验证** `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 | 2 | 0 | -| P1 | 2 | 1 | 1 | +| P0 | 2 | 1 | 1 | +| P1 | 3 | 1 | 2 | +| P2 | 2 | 0 | 2 | +| P3 | 4 | 0 | 4 | --- -## 🔴 P0 — 严重问题 +## 四、🔴 P0 — 严重问题 -### P0-1: 旧版 smtp.py 存在大量致命漏洞(需立即下线) ✅ 已修复 +### P0-1: 旧版 smtp.py 存在大量致命漏洞(已立即下线) ✅ 已修复 - **位置**: ~~`smtp.py`~~、`_run.sh` - **问题**: 旧版 smtp.py 缺乏关键防护: 1. 无发送速率限制(可被用于邮件炸弹) @@ -53,38 +76,276 @@ - **修复 (2026-08-04)**: 旧版 `smtp.py` 已**删除下线**,杜绝误启动;README 中相关描述已同步更新 ### P0-2: 依赖 pymsg 无认证(缓解:绑定 127.0.0.1) -- **说明**: 与其他 pymsg 服务相同,当前依赖网络层隔离 +- **位置**: `smtp_v2.py` L113-117 +- **说明**: 使用 pymsg RpcServer,无认证机制(与 pymsg P0-3 同源),依赖 127.0.0.1 网络隔离。当前风险由网络隔离承担,建议在 `SECURITY_HARDENING_PLAN` 的内网 mTLS/共享密钥方案中统一解决。 --- -## 🟠 P1 — 高危问题 +## 五、🟠 P1 — 高危问题 -| # | 问题 | 说明 | -|---|------|------| -| P1-1 | 验证码存储在进程内存,服务重启后所有未使用验证码失效 | 可被利用造成拒绝服务(频繁重启导致用户无法注册) | -| P1-2 | 全局发送熔断配置需确认生效(`SMTP_HOURLY_LIMIT`) | smtp_v2 已有但需验证环境变量正确加载 | +| # | 问题 | 状态 | 说明 | +|---|------|------|------| +| 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() +``` --- -## ✅ 已有安全机制(smtp_v2.py) +## 六、🟠 P2 — 中危问题 -1. ✅ **同邮箱 60 秒发送冷却**: 冷却期拒绝并返回 `retry_after` -2. ✅ **邮箱级限流**(gateway 层双窗口:3次/小时 + 10次/天) -3. ✅ **验证码一次性消费**: 校验通过立即删除,防重放 -4. ✅ **连续失败 5 次自动作废**: 防暴力破解验证码 -5. ✅ **10 分钟有效期**: 惰性过期清理 -6. ✅ **发送失败不计入冷却**: 防止网络错误误限制 -7. ✅ **全局熔断(可选)**: `SMTP_HOURLY_LIMIT` 默认 100 封/小时 -8. ✅ **fail-closed**: SMTP 异常时验证码校验失败 +### 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 — 低危问题(纵深防御) -### 立即(24小时) -- [x] 确认 smtp 服务运行的是 smtp_v2.py,旧版下线 ✅ 已确认(`_run.sh` 使用 `smtp_v2.py`) -- [x] 删除或隔离 smtp.py 防止误用 ✅ 已删除(2026-08-04) +### 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**:纳入整体内网加固与高可用规划 - [ ] 验证码存储考虑持久化(可选,当前内存实现适合单实例)