fischer-agentkit/docs/solutions/security-issues/portal-platform-security-re...

325 lines
18 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.

---
title: "Portal 平台安全与可靠性修复 — 通道、LLM 缓存、服务端关闭"
date: 2026-06-26
category: docs/solutions/security-issues
module: channels/llm/server
problem_type: security_issue
component: service_object
severity: mixed
priority_breakdown: {p1: 4, p2: 5, p3: 1}
symptoms:
- "WeCom webhook accepted replayed requests within an unlimited time window (no timestamp freshness check)"
- "Anonymous LLM requests polluted per-user cache namespace when user_id was None (per_user_namespace=True ignored)"
- "Webhook receive_message() exceptions returned HTTP 500, triggering platform retry storms"
- "App shutdown leaked httpx connections and dropped in-flight IM replies (no await on _pending_webhook_tasks / close_all_adapters)"
- "Feishu token cache TTL was 300s instead of ~6900s, causing token refresh storms (24x too short)"
root_cause: missing_validation
resolution_type: code_fix
tags: [webhook-security, replay-attack, cache-isolation, shutdown-cleanup, token-ttl, backpressure, n-plus-1, code-review-fixes]
related_components:
- channels/wecom
- channels/feishu
- llm/cache
- llm/cache_key
- llm/gateway
- llm/config
- server/app
- server/routes/channels
---
# Portal 平台安全与可靠性修复 — 通道、LLM 缓存、服务端关闭
## Root Cause
10 个修复共享三个共性根因:
1. **信任边界输入校验缺失**WeCom webhook 信任签名正确即放行缺时间戳新鲜度LLM 缓存信任 `user_id` 非空(缺 None 检查webhook `receive_message` 信任 payload 格式(缺异常隔离)。三处均在信任边界省略了输入校验。
2. **资源生命周期未闭合**:应用 shutdown 未调用 `close_all_adapters()`、未 await `_pending_webhook_tasks`httpx 连接与 IM 回复在进程退出时被丢弃。Feishu token TTL 设置过短300s vs 实际 ~6900s导致频繁刷新。
3. **缓存键设计缺陷**`generate_cache_key` 对每个组件单独 SHA-256 后再哈希一次8-10 次冗余哈希配额检查重复查询4 次/部门/周期 vs 实际需 1 次 summary
## Problem
`feat/portal-platform-evolution` 分支上完成了一次 ce-code-review 修复批次commit `53faa60`),针对代码评审发现的 10 个安全、可靠性与性能缺陷进行了集中修复。缺陷横跨通道接入WeCom/Feishu webhook、LLM 网关(缓存命名空间、配额查询)、应用生命周期(关闭泄漏)等子系统,按严重度分布为 4 个 P1安全/可靠性、5 个 P2效率/可维护性、1 个 P3去重
## Symptoms
- **WeCom webhook 重放风险**`verify_signature()` 仅校验签名正确性,不校验时间戳新鲜度,攻击者可无限重放历史合法请求。
- **LLM 缓存跨用户泄漏**`should_cache()` 对 `user_id` 参数执行 `_ = user_id` 直接丢弃,在 `per_user_namespace` 开启时,`user_id=None` 的请求仍会命中其他用户的缓存。
- **Webhook 异常触发 500 重试风暴**`receive_message()` 解析失败直接抛出未捕获异常,平台收到 500 后按退避策略无限重试,造成异常放大。
- **应用关闭资源泄漏**shutdown 流程未调用 `close_all_adapters()`、未 await `_pending_webhook_tasks`,导致 httpx 连接泄漏与 IM 回复丢失。
- **配额查询 N+1 与缓存键冗余哈希**:配额检查对每个部门每个周期调用 4 次 `get_usage()`(实际只需 1 次唯一查询token 与 cost 可共用 summary`generate_cache_key` 对每个组件单独 SHA-256 后再哈希一次8-10 次冗余哈希。
## What Didn't Work
- **预存在的测试失败(环境问题,非代码问题)**`litellm` 未安装导致依赖其的测试在本地跳过或报 collect error`jieba` 未安装导致分词相关测试失败。这些是环境依赖缺失,与本次修复无关,不应误判为回归。
- **WeCom 测试使用固定时间戳 `1609459200`2021-01-01**:在加入 `_SIGNATURE_MAX_AGE_SECONDS = 300` 新鲜度校验后,该固定时间戳距离当前时间已超出 5 分钟窗口,测试立即失败。需改用 `int(time.time())` 动态生成时间戳。
- **缓存测试以默认 `user_id=None` 调用 `should_cache()`**:安全修复后 `per_user_namespace=True & user_id=None` 会返回 `False`,原本默认参数的测试用例不再缓存,需显式传入 `user_id` 或在测试中关闭 `per_user_namespace`
## Solution
### P1 #1 — WeCom webhook 重放攻击修复
**文件**[src/agentkit/channels/wecom.py](src/agentkit/channels/wecom.py)
**问题**`verify_signature()` 只比对签名,不校验时间戳新鲜度,重放窗口无限大。
**修复**:新增 `_SIGNATURE_MAX_AGE_SECONDS = 300` 常量,在签名校验前先校验时间戳绝对偏差。
```python
_SIGNATURE_MAX_AGE_SECONDS = 300
# In verify_signature():
try:
ts_int = int(timestamp)
except (TypeError, ValueError):
return False
now = int(time.time())
if abs(now - ts_int) > _SIGNATURE_MAX_AGE_SECONDS:
logger.warning(
"企微 webhook 时间戳超出 %ds 窗口: ts=%s now=%d",
_SIGNATURE_MAX_AGE_SECONDS, timestamp, now,
)
return False
```
**说明**:使用绝对值 `abs(now - ts_int)` 同时防御未来时间戳与历史重放;窗口设为 300s 兼顾时钟漂移与攻击面。
### P1 #2 — LiteLLM 缓存跨用户泄漏修复
**文件**[src/agentkit/llm/cache.py](src/agentkit/llm/cache.py)
**问题**`should_cache()` 形参 `user_id` 被显式丢弃(`_ = user_id``per_user_namespace` 开启时 `user_id=None` 的请求仍会命中缓存,造成跨用户数据泄漏。
**修复**:重写 `should_cache()`,强制 per-user 命名空间安全。
```python
def should_cache(self, kb_caching_disabled: bool = False, user_id: str | None = None) -> bool:
if kb_caching_disabled:
return False
if self._config.per_user_namespace and user_id is None:
logger.debug("should_cache: per_user_namespace on but user_id=None — skip cache")
return False
return True
```
**说明**`per_user_namespace` 开启时,`user_id=None` 视为不可命名空间化的请求,直接跳过缓存,避免命中他人缓存块。
### P1 #3 — Webhook 异常风暴防御
**文件**[src/agentkit/server/routes/channels.py](src/agentkit/server/routes/channels.py)
**问题**`receive_message()` 抛出异常时,路由返回 500平台按重试策略无限重试形成异常风暴。
**修复**:在 URLVerification 处理之后兜底捕获异常,返回 `code:0` 抑制平台重试。
```python
except Exception as exc: # noqa: BLE001 — 防止 receive_message 异常导致 500 触发平台重试风暴
logger.warning("receive_message 解析失败 channel=%s: %s", channel_id, exc)
return {"code": 0, "msg": "invalid_payload"}
```
**说明**`code:0` 是平台约定的"已接收"信号,可终止重试;`BLE001` 广度捕获是有意为之webhook 入口必须吞掉所有解析异常。
### P1 #4 — 应用关闭泄漏修复
**文件**[src/agentkit/server/app.py](src/agentkit/server/app.py)
**问题**shutdown 流程未关闭 channel adapters、未等待后台 webhook 任务,导致 httpx 连接泄漏与 IM 回复丢失。
**修复**:在 calendar scheduler 停止后追加 adapter 关闭与 webhook 任务等待逻辑。
```python
from agentkit.server.routes.channels import _pending_webhook_tasks
if _pending_webhook_tasks:
logger.info("等待 %d 个后台 webhook 任务完成", len(_pending_webhook_tasks))
await asyncio.gather(*_pending_webhook_tasks, return_exceptions=True)
try:
from agentkit.server.routes.channels import close_all_adapters
await close_all_adapters()
except Exception:
logger.debug("close_all_adapters 异常已忽略")
```
**说明**`return_exceptions=True` 保证一个任务失败不阻断其他任务收尾;`close_all_adapters` 异常吞掉是因为关闭路径上再抛异常已无意义。
### P2 #8 — Feishu token TTL 修正
**文件**[src/agentkit/channels/feishu.py](src/agentkit/channels/feishu.py)
**问题**`_TOKEN_CACHE_TTL = 300.0` 比实际有效期2h短 24 倍,造成每 5 分钟强制刷新一次 token无谓增加 QPS。
**修复**`300.0` → `6900.0`2h - 5min 余量),并更新注释。
```python
_TOKEN_CACHE_TTL = 6900.0 # 2h - 5min 余量,避免临界点失效
```
### P2 #10 — 无界 webhook 任务集
**文件**[src/agentkit/server/routes/channels.py](src/agentkit/server/routes/channels.py)
**问题**`_pending_webhook_tasks` set 在高负载下无上限增长,可能耗尽内存与协程。
**修复**:加入上界检查(`_WEBHOOK_MAX_CONCURRENT * 2`),超限时返回 HTTP 429。
```python
if len(_pending_webhook_tasks) >= _WEBHOOK_MAX_CONCURRENT * 2:
logger.warning("webhook 后台任务积压 %d,拒绝新任务", len(_pending_webhook_tasks))
raise HTTPException(status_code=429, detail="服务器繁忙,请稍后重试")
```
**说明**2x 余量允许短时尖峰消化429 让客户端退避而非雪崩。
### P2 #12 — 配额检查 N+1 查询消除
**文件**[src/agentkit/llm/gateway.py](src/agentkit/llm/gateway.py)
**问题**:配额检查对每个部门每个周期调用 4 次 `get_usage()`token 与 cost 各一次,但实际可合并为一次查询),重复查询放大数据库压力。
**修复**:抽取 `_get_usage_summary` 每周期只查一次token 与 cost 共用 summary再通过 `_check_quota_value` 分别校验。
```python
for period in ("daily", "monthly"):
summary = self._get_usage_summary(dept_id, period)
current_tokens = int(summary.total_tokens)
current_cost_cents = float(summary.total_cost) * 100.0
await self._check_quota_value(quota_service, db, dept_id, period, "token_limit", current_tokens)
await self._check_quota_value(quota_service, db, dept_id, period, "cost_limit", current_cost_cents)
def _get_usage_summary(self, department_id: str, period: str) -> UsageSummary:
now = datetime.now(timezone.utc)
if period == "monthly":
start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
else:
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
return self._usage_tracker.get_usage(department_id=department_id, start_time=start, end_time=now)
```
**说明**:查询数从 4 次/部门/周期降至 1 次/部门/周期,下降 75%。
### P2 #13 — 缓存键冗余 SHA-256 消除
**文件**[src/agentkit/llm/cache_key.py](src/agentkit/llm/cache_key.py)
**问题**`generate_cache_key` 对每个组件单独 SHA-2568-10 次),再对哈希拼接再做一次 SHA-256CPU 浪费且无安全增益。
**修复**:改为单次 SHA-256使用 `\x1f`Unit Separator分隔组件防止组件内容注入分隔符。
```python
parts = [
f"m:{model}",
f"s:{system_prompt}",
f"msg:{json.dumps(messages, sort_keys=True, ensure_ascii=False)}",
f"t:{temperature:.2f}",
f"tools:{json.dumps(tools, sort_keys=True, ensure_ascii=False) if tools is not None else 'null'}",
f"tc:{tool_choice}",
f"mt:{max_tokens}",
]
if user_id is not None:
parts.append(f"u:{user_id}")
if kb_acl_hash is not None:
parts.append(f"a:{kb_acl_hash}")
combined = "\x1f".join(parts) # US (Unit Separator) 防止组件内容注入分隔符
return hashlib.sha256(combined.encode()).hexdigest()
```
**说明**:移除了 `_hash_str``_hash_json` 辅助函数;`\x1f` 是 ASCII 控制字符,正常文本中不会出现,天然防注入。
### P2 #18 — 移除未使用的 secrets_store 参数
**文件**[src/agentkit/llm/config.py](src/agentkit/llm/config.py)
**问题**`get_api_key()` 接受 `secrets_store` 形参但从未使用(同步方法无法 await 异步 `get_secret`),属于误导性 API。
**修复**:从签名中移除 `secrets_store` 参数。
```python
def get_api_key(self) -> str:
"""同步读取 API Key — 返回 plaintext。"""
if self.api_key_encrypted:
logger.debug("get_api_key: encrypted key set — use aget_api_key for decryption")
return self.api_key
```
**说明**:调用方需同步明文 key 时直接调用;需要解密时使用 `aget_api_key()`
### P3 #21 — DIRECT_CHAT 路径去重
**文件**[src/agentkit/server/routes/channels.py](src/agentkit/server/routes/channels.py)
**问题**DIRECT_CHAT 逻辑在主路径与 ReAct 回退路径中重复实现,维护时易漂移。
**修复**:抽取 `_direct_chat()` helper两条路径共用。
```python
async def _direct_chat(llm_gateway: Any, routing: Any) -> str:
"""DIRECT_CHAT 路径 — 直接调用 LLM主路径与 ReAct 回退共用)。"""
response = await llm_gateway.chat(
messages=[{"role": "user", "content": message.content}],
model=routing.model or "default",
)
return response.content
```
## Verification
修复完成后按以下方式验证:
- **ruff check**`src/` 干净,仅剩预存在的 `gui_mode` F821与本次修复无关
- **Channels 测试**`pytest tests/unit/channels/test_wecom.py` — 验证 `verify_signature``_SIGNATURE_MAX_AGE_SECONDS` 新鲜度校验。
- **配置迁移测试**`pytest tests/unit/llm/test_config_migration.py` — 全部通过。
- **配额强制测试**`pytest tests/unit/llm/test_quota_enforcement.py` — 11 passed。
- **litellm 相关测试**:因 `litellm` 未安装被 skip环境问题非代码问题
- **WeCom 测试调整**:将固定时间戳 `1609459200` 改为 `int(time.time())` 动态生成后,新鲜度校验测试通过。
- **缓存测试调整**:显式传入 `user_id` 或关闭 `per_user_namespace` 后,`should_cache()` 测试通过。
## Impact Scope
**受影响模块**
| 模块 | 文件 | 影响类型 |
|---|---|---|
| WeCom 通道 | `src/agentkit/channels/wecom.py` | 安全(重放防御) |
| Feishu 通道 | `src/agentkit/channels/feishu.py` | 可靠性TTL 修正) |
| LLM 缓存 | `src/agentkit/llm/cache.py` | 安全(跨用户泄漏) |
| LLM 缓存键 | `src/agentkit/llm/cache_key.py` | 性能(冗余哈希) |
| LLM 网关 | `src/agentkit/llm/gateway.py` | 可靠性(配额 N+1 |
| LLM 配置 | `src/agentkit/llm/config.py` | 可维护性(未用参数) |
| 应用生命周期 | `src/agentkit/server/app.py` | 可靠性shutdown 泄漏) |
| 通道路由 | `src/agentkit/server/routes/channels.py` | 可靠性webhook 风暴) |
**向后兼容性**所有修复向后兼容。WeCom 签名校验增加时间戳窗口后,历史合法请求在 300s 窗口内仍被接受Feishu TTL 从 300s 延长至 ~6900s 仅减少刷新频率缓存键生成逻辑变更不影响命中旧键自然过期shutdown 顺序变更仅影响关闭流程,不影响运行时行为。
**生产影响**WeCom 重放修复立即生效缓存跨用户泄漏修复消除潜在数据混淆shutdown 泄漏修复消除进程重启时的连接错误日志。无破坏性变更,无需停机部署。
## Prevention
### 安全
- **Webhook 签名必须校验时间戳新鲜度**:任何带时间戳的 webhook 签名校验都应同时验证 `abs(now - ts)` 在合理窗口内(建议 300s且使用绝对值防御未来时间戳。仅校验签名等于零防御。
- **Per-user 缓存命名空间必须强制 user_id 校验**`per_user_namespace=True` 时,`user_id=None` 必须跳过缓存,不可降级为全局缓存。`should_cache` 类方法应显式拒绝缺失 user_id 的请求。
- **缓存键分隔符使用 ASCII 控制字符**:拼接多组件生成哈希键时,使用 `\x1f`Unit Separator而非可见字符`:`、`|`),杜绝组件内容注入分隔符的攻击面。
### 可靠性
- **Webhook handler 必须兜底捕获异常**:任何 webhook 入口路由都应在业务逻辑外包一层 `except Exception`,返回平台约定的"已接收"信号(如 `code:0`),避免 500 触发重试风暴。`# noqa: BLE001` 是有意为之。
- **应用 shutdown 必须关闭资源与等待后台任务**shutdown 流程必须显式调用 `close_all_adapters()`、`await asyncio.gather(*_pending_webhook_tasks, return_exceptions=True)`,避免连接泄漏与回复丢失。
- **后台任务集必须有上界**:任何 `_pending_tasks` set 都应设置 `max_concurrent * 2` 上界,超限返回 429 让客户端退避,防止内存耗尽。
- **Token TTL 必须匹配真实有效期**:缓存 token 时 TTL 应为"真实有效期 - 5min 余量",避免临界点失效;不要凭直觉设短值。
### 性能
- **配额/统计类查询避免 N+1**:同一周期内 token 与 cost 共享一次 `get_usage()` 查询,不要为每个指标单独查询。抽取 `_get_usage_summary` 统一入口。
- **缓存键避免冗余哈希**:单次 SHA-256 足够安全,无需对每个组件单独哈希后再哈希。移除 `_hash_str` / `_hash_json` 类辅助函数。
### 测试
- **涉及时间戳的测试使用动态值**:不要硬编码 `1609459200` 等历史时间戳,统一用 `int(time.time())` 生成,避免新鲜度校验引入后测试立即失效。
- **涉及 per-user 命名的测试显式传入 user_id**:测试 `should_cache` 类方法时,显式传入 `user_id` 或在 fixture 中关闭 `per_user_namespace`,避免默认参数变更导致回归。
## Related Docs
- [long-horizon-reliability-code-review-fixes.md](docs/solutions/logic-errors/long-horizon-reliability-code-review-fixes.md) — 上一批 U1-U7 长期可靠性代码评审修复,与本批次同属 code-review 修复系列,可在遇到类似可靠性问题时交叉参考。
- [bitable-companion-service-security-reliability-patterns.md](docs/solutions/architecture-patterns/bitable-companion-service-security-reliability-patterns.md) — Bitable 伴生服务的安全/可靠性架构模式SSRF、SQL 注入、IDOR、缓存失效等与本批次的 LLM 缓存隔离威胁模型不同但可对照阅读。