fix(captcha): 验证码存储容量上限防 OOM (P1-1)

新增 MAX_STORE_SIZE(默认 10000,可配置 max_store/CAPTCHA_MAX_STORE),
_handle_image 在清理过期项后检查容量,超限拒绝生成并返回错误,
防止攻击者高频生成验证码填满内存。
This commit is contained in:
qing committed 2026-08-04 18:40:36 +08:00
1 parent 4d9bb5d940
commit fe618be1fa
2 files changed
+27 -8

No files matched your search

+21 -2
View File
@@ -50,7 +50,7 @@ _confs_port = int(os.environ.get('CONFS_PORT', '8010'))
# 白名单结构:_merge_ini 仅覆盖此处列出的 section/key,防止意外字段 # 白名单结构:_merge_ini 仅覆盖此处列出的 section/key,防止意外字段
_captcha_config = { _captcha_config = {
'server': {'host': '', 'port': '', 'workers': ''}, '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'] _captcha_config['server']['workers'] = os.environ['WORKERS']
if os.environ.get('CAPTCHA_FONT_PATH'): if os.environ.get('CAPTCHA_FONT_PATH'):
_captcha_config['captcha']['font_path'] = os.environ['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 = { _required = {
'server': ['host', 'port', 'workers'], 'server': ['host', 'port', 'workers'],
'captcha': ['len', 'ttl'], 'captcha': ['len', 'ttl'],
} }
# max_store 是可选配置(非必填)
for section, keys in _required.items(): for section, keys in _required.items():
for key in keys: for key in keys:
if not _captcha_config[section][key]: if not _captcha_config[section][key]:
@@ -115,6 +118,14 @@ for section, keys in _required.items():
CAPTCHA_LEN = int(_captcha_config['captcha']['len']) CAPTCHA_LEN = int(_captcha_config['captcha']['len'])
CAPTCHA_TTL = int(_captcha_config['captcha']['ttl']) 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())。 # 即使默认值文件不存在,PIL 在 truetype() 失败时会回退到 load_ Default())。
DEFAULT_FONT_PATH = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf' DEFAULT_FONT_PATH = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'
@@ -207,13 +218,21 @@ class CaptchaServer:
token = secrets.token_hex(16) token = secrets.token_hex(16)
with STORE_LOCK: with STORE_LOCK:
captcha_store[token] = {'answer': text.lower(), 'created_at': time.time()}
# 惰性清理过期项 # 惰性清理过期项
now = time.time() now = time.time()
expired = [k for k, v in captcha_store.items() if now - v['created_at'] > CAPTCHA_TTL] expired = [k for k, v in captcha_store.items() if now - v['created_at'] > CAPTCHA_TTL]
for k in expired: for k in expired:
del captcha_store[k] 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) png_bytes = generate_image(text)
logs.info("captcha", f"验证码已生成 token={token[:8]}...") logs.info("captcha", f"验证码已生成 token={token[:8]}...")
return { return {
@@ -16,15 +16,15 @@
| 等级 | 总数 | 已修复 | 部分修复 | 未修复 | 修复率 | | 等级 | 总数 | 已修复 | 部分修复 | 未修复 | 修复率 |
|------|------|--------|----------|--------|--------| |------|------|--------|----------|--------|--------|
| P0 | 0 | 0 | 0 | 0 | - | | P0 | 0 | 0 | 0 | 0 | - |
| P1 | 1 | 0 | 0 | 1 | 0% | | P1 | 1 | 1 | 0 | 0 | 100% |
| P2 | 1 | 0 | 0 | 1 | 0% | | 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;当前单实例可接受 | | P2-1 | ❌ 未修复 | `captcha_service.py` L128 | 仍为进程内存 dict,未迁移至 Redis;当前单实例可接受 |
--- ---
@@ -34,7 +34,7 @@
| 优先级 | 总数 | 已修复 | 待修复 | | 优先级 | 总数 | 已修复 | 待修复 |
|--------|------|--------|--------| |--------|------|--------|--------|
| P0 | 0 | 0 | 0 | | P0 | 0 | 0 | 0 |
| P1 | 1 | 0 | 1 | | P1 | 1 | 1 | 0 |
| P2 | 1 | 0 | 1 | | 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 个) - [ ] 添加生成速率限制(单 IP 每分钟最多生成 N 个)
- [ ] 将 `generate_text()` 中的 `random.choices` 替换为 `secrets` 模块 - [ ] 将 `generate_text()` 中的 `random.choices` 替换为 `secrets` 模块