Files
blackbird/docs/security/smtp_SECURITY_AUDIT_20260801.md
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

15 KiB
Raw Permalink Blame History

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 在邮箱级限流预占阶段记录时间戳:

# 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

随后在写入验证码的锁块内重新赋值了同名变量:

# 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:

# 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 比较,该部分仍正确;仅限流/熔断预占回滚被破坏。

修复建议

引入独立变量承载预占时间戳,并在过期清理处使用局部变量,避免遮蔽:

# —— 限流预占阶段(原 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(中)

问题描述

logs.info("smtp", f"{email}: 验证码发送成功({code})")   # L250

log_client 会把该消息经 pymsg 异步 INSERT 进 db 服务的 Logs 表(log_client.py L214-224)。验证码在 10 分钟有效期内以明文持久化在数据库,任何能读取 db.Logs 的角色(运维、具备数据库访问权的内部服务、被攻陷的依赖)都可在有效期内批量收割有效验证码,直接绕过邮箱验证。

影响

  • 绕过注册/找回密码的邮箱验证环节,危害等同验证码接口暴露。
  • 违反 OTP 处理最佳实践(验证码不应以可恢复形式落盘)。

修复建议

日志中去除验证码明文,仅记录事件与邮箱:

logs.info("smtp", f"{email}: 验证码发送成功")   # 去掉 ({code})

如确需可追溯,可记入验证码的哈希(如 hashlib.sha256(code.encode()).hexdigest()[:8]),但推荐不记录。

P2-F3:验证码使用非密码学安全随机源 random

  • 位置: smtp_v2.py L201
  • 性质: 弱随机源(OTP 可预测性)
  • 严重程度: P2(中,纵深防御)

问题描述

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 模块:

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(低)
if entry and entry['code'] == code:   # L271

普通 == 字符串比较为短路比较,理论上存在微小时序差。当前每次错误尝试都会递增 attempts 并在 5 次后作废,实际可利用性极低,但使用常数时间比较是 OTP 校验的规范做法。

修复建议:

import hmac
if entry and hmac.compare_digest(entry['code'], code):
    ...

P3-F5:smtp 内部邮箱格式校验过弱

  • 位置: smtp_v2.py L153
  • 性质: 输入校验(纵深防御)
  • 严重程度: P3(低)
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(低)
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(低)
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 纳入防护网。


十一、修复路线图

立即(本周)

  • 确认 smtp 服务运行的是 smtp_v2.py,旧版下线 ✅ 已确认(_run.sh 使用 smtp_v2.py,2026-08-04 删除旧版)
  • 验证全局熔断配置生效 ✅ 已确认
  • P1-F1:重命名限流预占时间戳变量,修复回滚(提供代码见 §五)
  • P2-F2:从日志中移除验证码明文

短期

  • P2-F3:random → secrets
  • P3-F4:hmac.compare_digest 比较验证码
  • 补充 §十 列出的回归测试用例

跟踪

  • P0-2 / P1-1:纳入整体内网加固与高可用规划
  • 验证码存储考虑持久化(可选,当前内存实现适合单实例)