256 lines
14 KiB
Markdown
256 lines
14 KiB
Markdown
---
|
||
title: "fix: P0 安全与多实例一致性加固"
|
||
date: 2026-06-26
|
||
type: fix
|
||
status: planned
|
||
origin: 代码走查报告(portal-platform-evolution 合并后审查)
|
||
---
|
||
|
||
# fix: P0 安全与多实例一致性加固
|
||
|
||
## Summary
|
||
|
||
修复代码走查发现的 4 项高优先级问题,分两组:(1) 安全 fail-open 默认值 — Gateway KB cache fail-closed + MCP dangerous-tool 黑名单过滤;(2) 多实例部署一致性 — SecretsStore 与 Channels 全局状态迁移到 Redis,消除多 worker 下的安全与状态共享缺口。
|
||
|
||
## Problem Frame
|
||
|
||
`portal-platform-evolution` 合并引入了多端渠道(U10-U12)、MCP 协议(U13/U16)、LiteLLM 缓存(U17)等特性。代码走查发现两类系统性风险:
|
||
|
||
1. **安全 fail-open**:Gateway 在 KB settings 读取异常时默认启用缓存(可能泄漏禁用缓存的 KB 数据);MCP 端点暴露所有工具(含 ShellTool 等危险工具),绕过 chat 流程的 confirmation 机制。
|
||
2. **多实例不一致**:SecretsStore 和 Channels 路由使用模块级内存字典存储凭证、nonce、限流、backpressure 状态,多 worker 部署下失效,与 README 宣称的 K8s 多实例部署目标冲突。
|
||
|
||
## Requirements
|
||
|
||
- **R1**:Gateway KB cache 在 settings 读取失败时必须 fail-closed(禁用缓存),不得 fail-open
|
||
- **R2**:MCP `/tools/call` 和 `/tools/list`(含 JSON-RPC 端点)必须过滤危险工具,禁止绕过 chat confirmation 流程
|
||
- **R3**:SecretsStore 加密凭证必须存储在 Redis,多 worker 共享,保留 AES-256-GCM 加密层
|
||
- **R4**:Channels webhook nonce dedup、rate limit、backpressure、渠道配置必须存储在 Redis,多 worker 一致
|
||
- **R5**:所有改动不破坏现有单进程行为(Redis 不可用时降级到内存,需显式 log warning)
|
||
- **R6**:每个修复单元包含可运行的验证测试
|
||
|
||
## Key Technical Decisions
|
||
|
||
### KTD1: H4 危险工具过滤采用黑名单
|
||
|
||
**决策**:使用 `_MCP_BLOCKED_TOOLS: frozenset[str]` 黑名单,而非在 Tool 基类增加 `requires_confirmation` 元数据字段。
|
||
|
||
**理由**:黑名单改动最小(ponytail 原则),仅影响 mcp/server.py 一个文件。元数据方案需修改 Tool ABC + 所有工具子类 + ToolRegistry,改动面过大。黑名单天花板已在代码注释标注(`ponytail: 黑名单需手动维护,新增危险工具需同步更新`)。
|
||
|
||
### KTD2: H1/H2 Redis 连接复用 app.state.working_redis_client
|
||
|
||
**决策**:复用 `app.py` lifespan 已创建的 `app.state.working_redis_client`(`aioredis.from_url(redis_url, decode_responses=True)`),通过 FastAPI `Depends` 或 `request.app.state` 注入到 channels 路由和 SecretsStore。
|
||
|
||
**理由**:避免新建 Redis 连接池(资源浪费),与 WorkingMemory、TaskManager 等现有子系统共享同一连接。`decode_responses=True` 与 RedisBus 模式一致。
|
||
|
||
### KTD3: 降级策略 — Redis 不可用时 fail-closed
|
||
|
||
**决策**:Redis 连接失败时,webhook 端点返回 503(而非降级到内存),secrets 读取返回 None(而非空字典)。
|
||
|
||
**理由**:安全优先。降级到内存会导致多 worker 间状态不一致,nonce dedup 失效可能引发重放攻击。fail-closed 符合 AGENTS.md 安全约定。
|
||
|
||
## Implementation Units
|
||
|
||
### U1. Gateway KB cache fail-closed
|
||
|
||
**Goal**: KB settings 读取异常时禁用缓存,而非默认启用。
|
||
|
||
**Requirements**: R1
|
||
|
||
**Dependencies**: 无
|
||
|
||
**Files**:
|
||
- `src/agentkit/llm/gateway.py`(修改第 137-146 行)
|
||
- `tests/unit/llm/test_gateway_cache_failclosed.py`(新建)
|
||
|
||
**Approach**: 将 `kb_caching_disabled` 默认值从 `False` 改为 `True`(fail-closed),仅在 settings 成功读取且 `caching_disabled=False` 时才启用缓存。读取异常或 settings 为 None 时保持 `True`。
|
||
|
||
**Patterns to follow**: 现有 `should_cache` 已有 `per_user_namespace + user_id=None` 防护逻辑,本单元补齐 KB settings 读取层的防护。
|
||
|
||
**Test scenarios**:
|
||
- **Happy path**: settings 正常读取 `caching_disabled=False` → 缓存启用,`should_cache` 返回 True
|
||
- **Happy path**: settings 正常读取 `caching_disabled=True` → 缓存禁用,`should_cache` 返回 False
|
||
- **Error path**: `get_settings_store().get_settings()` 抛异常 → `kb_caching_disabled=True`,`should_cache` 返回 False(fail-closed)
|
||
- **Edge case**: settings 返回 None(KB 不存在)→ `kb_caching_disabled=True`
|
||
- **Edge case**: `kb_id=None`(非 RAG 请求)→ 不查 settings,缓存正常启用
|
||
|
||
**Verification**: `pytest tests/unit/llm/test_gateway_cache_failclosed.py -v` 全绿;mock settings store 抛异常时断言 `should_cache` 返回 False。
|
||
|
||
---
|
||
|
||
### U2. MCP dangerous-tool 黑名单过滤
|
||
|
||
**Goal**: 阻止危险工具通过 MCP 端点执行,绕过 chat confirmation 流程。
|
||
|
||
**Requirements**: R2
|
||
|
||
**Dependencies**: 无
|
||
|
||
**Files**:
|
||
- `src/agentkit/mcp/server.py`(修改 `_all_tools`、`_find_tool`、REST `call_tool`、JSON-RPC `tools/call`)
|
||
- `tests/unit/mcp/test_server_dangerous_tools.py`(新建)
|
||
|
||
**Approach**: 在 `server.py` 顶部定义 `_MCP_BLOCKED_TOOLS` 黑名单(`shell`、`file_write`、`file_delete`、`terminal_execute`)。`_all_tools()` 和 `_find_tool()` 过滤黑名单工具。`list_tools` 和 JSON-RPC `tools/list` 不返回黑名单工具。`call_tool` 对黑名单工具返回 404。
|
||
|
||
**Technical design**(directional guidance):
|
||
|
||
```python
|
||
# ponytail: 黑名单需手动维护,新增危险工具需同步更新。
|
||
# 天花板:若工具名动态生成或别名注册,黑名单可能漏判。
|
||
# 升级路径:Tool 基类增加 requires_confirmation 元数据字段。
|
||
_MCP_BLOCKED_TOOLS: frozenset[str] = frozenset({
|
||
"shell", "file_write", "file_delete", "terminal_execute",
|
||
})
|
||
```
|
||
|
||
**Patterns to follow**: `shell.py` 已有 `_DANGEROUS_BINARIES` frozenset 模式。
|
||
|
||
**Test scenarios**:
|
||
- **Happy path**: `GET /tools/list` 不返回黑名单工具
|
||
- **Happy path**: `POST /tools/call` with `{"name": "safe_tool"}` 正常执行
|
||
- **Error path**: `POST /tools/call` with `{"name": "shell"}` → 404
|
||
- **Error path**: `POST /tools/call` with `{"name": "file_delete"}` → 404
|
||
- **Integration**: JSON-RPC `tools/call` with `{"method": "tools/call", "params": {"name": "shell"}}` → `isError: True`
|
||
- **Edge case**: JSON-RPC `tools/list` 不包含黑名单工具
|
||
|
||
**Verification**: `pytest tests/unit/mcp/test_server_dangerous_tools.py -v` 全绿;黑名单工具在 list 和 call 端点均不可见。
|
||
|
||
---
|
||
|
||
### U3. SecretsStore Redis 迁移
|
||
|
||
**Goal**: 加密凭证存储迁移到 Redis,多 worker 共享,保留 AES-256-GCM 加密层。
|
||
|
||
**Requirements**: R3, R5
|
||
|
||
**Dependencies**: 无(可与 U4 并行,共享 Redis 注入基础设施)
|
||
|
||
**Files**:
|
||
- `src/agentkit/channels/secrets.py`(修改 `SecretsStore.__init__`、`set_secret`、`get_secret`、`delete_secret`、`list_keys`)
|
||
- `src/agentkit/server/routes/channels.py`(修改 `_get_secrets_store` 注入 Redis)
|
||
- `src/agentkit/server/app.py`(lifespan 中将 `working_redis_client` 传入 SecretsStore)
|
||
- `tests/unit/channels/test_secrets_redis.py`(新建)
|
||
|
||
**Approach**: `SecretsStore.__init__` 新增 `redis` 参数(`aioredis.Redis | None`)。`_store` 字典替换为 Redis 调用:`set_secret` → `redis.set(f"secrets:{key}", entry.model_dump_json())`;`get_secret` → `redis.get` + `SecretEntry.model_validate_json`;`delete_secret` → `redis.delete`;`list_keys` → `redis.scan_iter`。加密层(`encrypt`/`decrypt`/`_derive_key`)不变。Redis 为 None 时降级到内存(仅开发模式,log warning)。
|
||
|
||
**Patterns to follow**: `RedisBus._get_redis()` 懒初始化模式;`SharedWorkspace` 的 `redis_client` 注入模式。
|
||
|
||
**Test scenarios**:
|
||
- **Happy path**: `set_secret("k", "v")` 后 `get_secret("k")` 返回 `"v"`(Redis mock)
|
||
- **Happy path**: `delete_secret("k")` 后 `get_secret("k")` 返回 None
|
||
- **Happy path**: `list_keys(prefix="feishu:")` 返回匹配前缀的 key 列表
|
||
- **Edge case**: Redis 为 None(降级模式)→ 内存字典行为,log warning
|
||
- **Error path**: Redis 连接失败 → `get_secret` 抛异常或返回 None(fail-closed)
|
||
- **Integration**: 加密-存储-读取-解密往返:明文 → encrypt → Redis SET → Redis GET → decrypt → 明文一致
|
||
|
||
**Verification**: `pytest tests/unit/channels/test_secrets_redis.py -v` 全绿;多 worker 场景下 worker A `set_secret` 后 worker B `get_secret` 能读到(需 Redis faker 或集成测试)。
|
||
|
||
---
|
||
|
||
### U4. Channels 全局状态 Redis 迁移
|
||
|
||
**Goal**: nonce dedup、rate limit、backpressure、渠道配置迁移到 Redis,多 worker 一致。
|
||
|
||
**Requirements**: R4, R5, R6
|
||
|
||
**Dependencies**: U3(共享 Redis 注入基础设施)
|
||
|
||
**Files**:
|
||
- `src/agentkit/server/routes/channels.py`(修改 `_channels`、`_rate_limits`、`_seen_nonces`、`_pending_webhook_tasks` 相关逻辑)
|
||
- `tests/unit/channels/test_webhook_redis_state.py`(新建)
|
||
|
||
**Approach**:
|
||
|
||
1. **nonce dedup** → Redis `SET NX EX`:`await redis.set(f"nonce:{nonce}", "1", ex=300, nx=True)` 返回 None 表示重复。
|
||
2. **rate limit** → Redis ZSET 滑动窗口:`ZREMRANGEBYSCORE` 清理过期 + `ZADD` + `ZCARD` 计数。
|
||
3. **backpressure** → Redis `INCR`/`DECR` 共享计数器:`INCR webhook:concurrent`,超限 `DECR` 并返回 429,执行完毕 `DECR`。`EXPIRE 30s` 防 crash 计数不归零。
|
||
4. **渠道配置** → Redis Hash:`HSET channel:{id}` 存配置 JSON,`HGETALL` 读取。`_adapter_cache` 保留进程内缓存(只读快照,配置变更时 invalidate)。
|
||
5. **Redis 注入**:webhook handler 通过 `request.app.state.working_redis_client` 获取连接。
|
||
|
||
**Technical design**(directional guidance):
|
||
|
||
```python
|
||
# nonce dedup — 原子操作,TTL 自动过期
|
||
is_new = await redis.set(f"nonce:{nonce}", "1", ex=int(_NONCE_TTL), nx=True)
|
||
if not is_new:
|
||
return Response(status_code=200) # 重复事件,飞书要求 3s 内响应
|
||
|
||
# rate limit — ZSET 滑动窗口
|
||
key = f"ratelimit:{client_ip}"
|
||
now = time.time()
|
||
pipe = redis.pipeline()
|
||
pipe.zremrangebyscore(key, 0, now - _RATE_LIMIT_WINDOW)
|
||
pipe.zadd(key, {str(now): now})
|
||
pipe.zcard(key)
|
||
pipe.expire(key, int(_RATE_LIMIT_WINDOW))
|
||
_, _, count, _ = await pipe.execute()
|
||
if count > _RATE_LIMIT_MAX:
|
||
return Response(status_code=429)
|
||
|
||
# backpressure — 共享计数器
|
||
current = await redis.incr("webhook:concurrent")
|
||
if current > _WEBHOOK_MAX_CONCURRENT * 2:
|
||
await redis.decr("webhook:concurrent")
|
||
return Response(status_code=429)
|
||
try:
|
||
# ... process webhook ...
|
||
finally:
|
||
await redis.decr("webhook:concurrent")
|
||
```
|
||
|
||
**Patterns to follow**: Redis pipeline 模式(`usage_store.py` 已有);`_adapter_cache` 保留进程内缓存(`_invalidate_adapter_cache` 逻辑不变)。
|
||
|
||
**Test scenarios**:
|
||
- **Happy path**: 首次 nonce → `SET NX` 返回 True,请求正常处理
|
||
- **Happy path**: 重复 nonce → `SET NX` 返回 None,返回 200(不重复处理)
|
||
- **Happy path**: rate limit 窗口内 100 请求通过,第 101 请求返回 429
|
||
- **Happy path**: backpressure 并发 < 2x 上限时请求通过
|
||
- **Error path**: backpressure 并发 >= 2x 上限时返回 429
|
||
- **Edge case**: nonce TTL 过期后相同 nonce 可再次使用(mock `time.sleep` 或 Redis TTL)
|
||
- **Edge case**: rate limit 窗口滚动后计数重置
|
||
- **Integration**: 渠道配置写入 Redis 后,新 worker 的 `_adapter_cache` miss 时从 Redis 读取并缓存
|
||
|
||
**Verification**: `pytest tests/unit/channels/test_webhook_redis_state.py -v` 全绿;`ruff check src/ && ruff format src/` 通过;现有 `tests/unit/channels/test_wecom.py` 等不回归。
|
||
|
||
---
|
||
|
||
## Scope Boundaries
|
||
|
||
### In Scope
|
||
|
||
- 4 项高优先级问题修复(H3/H4/H1/H2)
|
||
- Redis 连接复用现有 `app.state.working_redis_client`
|
||
- 单元测试覆盖每个修复单元
|
||
|
||
### Out of Scope
|
||
|
||
- 中优先级问题(M1-M8):feishu/slack decode 异常、dingtalk 时间戳窗口、mcp/client 性能、app.py shutdown 超时等
|
||
- 低优先级问题(L1-L3):X-Forwarded-For 信任、生产 guard 触发条件、Any 类型
|
||
- PostgreSQL 持久化迁移(Redis 已满足多实例共享需求)
|
||
- MCP confirmation 协议实现(黑名单方案足够,元数据方案为后续升级路径)
|
||
- Tool 基类 `requires_confirmation` 元数据重构
|
||
|
||
### Deferred to Follow-Up Work
|
||
|
||
- M1 feishu/slack `body.decode("utf-8")` 异常捕获 → 独立 PR
|
||
- M4 mcp/server.py 异常消息脱敏 + REST/JSON-RPC 代码去重 → 独立 PR
|
||
- M6 app.py shutdown `asyncio.gather` 超时 → 独立 PR
|
||
- H4 升级路径:Tool 基类 `requires_confirmation` 元数据 → 下个迭代
|
||
|
||
## Risks & Dependencies
|
||
|
||
- **风险 1**:H1/H2 Redis 迁移后,Redis 不可用会导致 webhook 端点完全失效(fail-closed)。缓解:lifespan 启动时 ping Redis,不可用时 log error 但不阻止启动(单进程降级模式仍可用)。
|
||
- **风险 2**:U4 backpressure `INCR/DECR` 非原子,crash 可能导致计数不归零。缓解:`EXPIRE 30s` 安全网 + 定期清理。
|
||
- **依赖 1**:U4 依赖 U3 完成的 Redis 注入基础设施。
|
||
- **依赖 2**:所有单元测试使用 `fakeredis` 或 `unittest.mock.AsyncMock` mock Redis,不依赖真实 Redis 实例。
|
||
|
||
## System-Wide Impact
|
||
|
||
- **运维**:多 worker 部署(gunicorn -w 4)现在可正确共享渠道状态,无需 sticky session
|
||
- **安全**:MCP 端点不再暴露危险工具;KB 缓存 fail-closed 防止数据泄漏
|
||
- **性能**:Redis 网络往返替代内存字典,单请求延迟增加 ~1ms(可接受);backpressure 跨 worker 生效
|
||
- **兼容性**:单进程开发模式(无 Redis)降级到内存存储,行为不变
|
||
|
||
## Open Questions
|
||
|
||
无 — 所有技术决策已在 KTD1-KTD3 中明确。
|