From fe618be1fa8e6f2aceb016e57ac174869d3cd5bd Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Tue, 4 Aug 2026 18:40:36 +0800 Subject: [PATCH] =?UTF-8?q?fix(captcha):=20=E9=AA=8C=E8=AF=81=E7=A0=81?= =?UTF-8?q?=E5=AD=98=E5=82=A8=E5=AE=B9=E9=87=8F=E4=B8=8A=E9=99=90=E9=98=B2?= =?UTF-8?q?=20OOM=20(P1-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 MAX_STORE_SIZE(默认 10000,可配置 max_store/CAPTCHA_MAX_STORE), _handle_image 在清理过期项后检查容量,超限拒绝生成并返回错误, 防止攻击者高频生成验证码填满内存。 --- captcha/captcha_service.py | 23 +++++++++++++++++-- .../captcha_SECURITY_AUDIT_20260801.md | 12 +++++----- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/captcha/captcha_service.py b/captcha/captcha_service.py index 8df2d1b..b00c05e 100644 --- a/captcha/captcha_service.py +++ b/captcha/captcha_service.py @@ -50,7 +50,7 @@ _confs_port = int(os.environ.get('CONFS_PORT', '8010')) # 白名单结构:_merge_ini 仅覆盖此处列出的 section/key,防止意外字段 _captcha_config = { 'server': {'host': '', 'port': '', 'workers': ''}, - 'captcha': {'len': '', 'ttl': '', 'font_path': ''}, + 'captcha': {'len': '', 'ttl': '', 'font_path': '', 'max_store': ''}, } @@ -98,12 +98,15 @@ if os.environ.get('WORKERS'): _captcha_config['server']['workers'] = os.environ['WORKERS'] if os.environ.get('CAPTCHA_FONT_PATH'): _captcha_config['captcha']['font_path'] = os.environ['CAPTCHA_FONT_PATH'] +if os.environ.get('CAPTCHA_MAX_STORE'): + _captcha_config['captcha']['max_store'] = os.environ['CAPTCHA_MAX_STORE'] # 配置完整性校验 _required = { 'server': ['host', 'port', 'workers'], 'captcha': ['len', 'ttl'], } +# max_store 是可选配置(非必填) for section, keys in _required.items(): for key in keys: if not _captcha_config[section][key]: @@ -115,6 +118,14 @@ for section, keys in _required.items(): CAPTCHA_LEN = int(_captcha_config['captcha']['len']) CAPTCHA_TTL = int(_captcha_config['captcha']['ttl']) +# 验证码存储容量上限(可选配置,默认 10000 个),防止内存耗尽(审计 P1-1) +_default_max_store = '10000' +MAX_STORE_SIZE = int(_captcha_config['captcha']['max_store'] or _default_max_store) +if MAX_STORE_SIZE <= 0: + print('[captcha] 致命错误: 配置项 [captcha] max_store 必须为正整数', + file=_sys.stderr) + _sys.exit(1) + # 字体路径:可选配置项(缺失或用空值时不致命,回退到代码默认值; # 即使默认值文件不存在,PIL 在 truetype() 失败时会回退到 load_ Default())。 DEFAULT_FONT_PATH = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf' @@ -207,13 +218,21 @@ class CaptchaServer: token = secrets.token_hex(16) with STORE_LOCK: - captcha_store[token] = {'answer': text.lower(), 'created_at': time.time()} # 惰性清理过期项 now = time.time() expired = [k for k, v in captcha_store.items() if now - v['created_at'] > CAPTCHA_TTL] for k in expired: del captcha_store[k] + # 容量上限保护(P1-1):清理过期后仍超限则拒绝新生成,防止内存耗尽 + if len(captcha_store) >= MAX_STORE_SIZE: + logs.warning("captcha", + f"验证码存储已满({len(captcha_store)}/{MAX_STORE_SIZE}),拒绝生成") + return {"success": False, + "error": "too many pending captchas, please try again later"} + + captcha_store[token] = {'answer': text.lower(), 'created_at': now} + png_bytes = generate_image(text) logs.info("captcha", f"验证码已生成 token={token[:8]}...") return { diff --git a/docs/security/captcha_SECURITY_AUDIT_20260801.md b/docs/security/captcha_SECURITY_AUDIT_20260801.md index 5ed01cc..31aa286 100644 --- a/docs/security/captcha_SECURITY_AUDIT_20260801.md +++ b/docs/security/captcha_SECURITY_AUDIT_20260801.md @@ -16,15 +16,15 @@ | 等级 | 总数 | 已修复 | 部分修复 | 未修复 | 修复率 | |------|------|--------|----------|--------|--------| | P0 | 0 | 0 | 0 | 0 | - | -| P1 | 1 | 0 | 0 | 1 | 0% | +| P1 | 1 | 1 | 0 | 0 | 100% | | P2 | 1 | 0 | 0 | 1 | 0% | -| **合计** | **2** | **0** | **0** | **2** | **0%** | +| **合计** | **2** | **1** | **0** | **1** | **50%** | ### 逐条验证 | 编号 | 状态 | 代码位置 | 验证说明 | |------|------|----------|----------| -| P1-1 | ❌ 未修复 | `captcha_service.py` L128-129, L204-222 | `captcha_store` dict 无容量上限,`_handle_image` 无生成速率限制,大量请求可耗尽内存 | +| P1-1 | ✅ 已修复 | `captcha_service.py` L227-232 | 新增 `MAX_STORE_SIZE`(默认 10000,可配置 `max_store`/`CAPTCHA_MAX_STORE`),`_handle_image` 在加锁后先清理过期项,再检查容量,超限拒绝生成并返回错误,防止内存耗尽 | | P2-1 | ❌ 未修复 | `captcha_service.py` L128 | 仍为进程内存 dict,未迁移至 Redis;当前单实例可接受 | --- @@ -34,7 +34,7 @@ | 优先级 | 总数 | 已修复 | 待修复 | |--------|------|--------|--------| | P0 | 0 | 0 | 0 | -| P1 | 1 | 0 | 1 | +| P1 | 1 | 1 | 0 | | P2 | 1 | 0 | 1 | --- @@ -43,7 +43,7 @@ | # | 问题 | 说明 | |---|------|------| -| P1-1 | 验证码存储无容量限制,大量生成可耗尽内存 | 无验证码数量上限,攻击者可高频调用 `/captcha/image` 生成大量验证码填满内存 | +| P1-1 | ~~验证码存储无容量限制,大量生成可耗尽内存~~ | ✅ 已修复(2026-08-04):新增 `MAX_STORE_SIZE`(默认 10000),`_handle_image` 清理过期后容量仍满则拒绝生成并返回 `too many pending captchas`,防止内存耗尽 | --- @@ -94,7 +94,7 @@ ## 修复路线图 ### 短期 -- [ ] 添加验证码存储容量上限(如最多 10000 个,超出时清理最早的) +- [x] 添加验证码存储容量上限(默认 10000,超出时拒绝新生成)✅ 已修复(2026-08-04) - [ ] 添加生成速率限制(单 IP 每分钟最多生成 N 个) - [ ] 将 `generate_text()` 中的 `random.choices` 替换为 `secrets` 模块