Skip to content

fix(mcp): #459 身份透传功能 3 轮对抗式审查(修复 22 项发现) - #540

Open
ncw1992120 wants to merge 3 commits into
mateaix:devfrom
ncw1992120:review/mcp-459-adversarial
Open

fix(mcp): #459 身份透传功能 3 轮对抗式审查(修复 22 项发现)#540
ncw1992120 wants to merge 3 commits into
mateaix:devfrom
ncw1992120:review/mcp-459-adversarial

Conversation

@ncw1992120

@ncw1992120 ncw1992120 commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

概述

对已合入 upstream/dev 的 ISSUE #459 实现(STDIO MCP server on-behalf-of 身份透传,经 #460 / #471 / #464 合入)进行 3 轮对抗式代码审查,修复全部确认的问题。共 22 项发现,新增 31 个测试(身份透传子集 23 → 48,更广泛的回归 287 个测试全绿)。

为什么需要这个 PR

ISSUE #459 已经合入 upstream/dev,但一轮聚焦的对抗式审查暴露了已合并代码中的真实缺陷:签名密钥轮换 bug、回放/排队消息路径丢失不可变用户 id、文档与代码漂移(会导致后端静默授权匿名访客)、一条安全关键但零测试的进度通知路径,以及——最关键的——部署产物缺失导致该功能根本无法通过文档描述的 Docker 路径部署。本 PR 修复全部问题并用测试锁定。

3 轮审查详情

第 1 轮 — 安全 / 并发 / 契约一致性(8 项修复)

  • HIGH signingKey() 缓存后短路 → 合法→合法的 PEM 轮换在重启前不生效(与 self-heal Javadoc 矛盾;在退役疑似泄露密钥时危险)。
  • HIGH 回放 / 排队消息的 web 来源丢失了 requesterUserId → on-behalf-of 的 sub 退回到可变用户名并被标记为 authenticated,而这恰恰是最需要 OBO 正确性的路径。已将 requesterUserIdOf(auth) 贯通到两条路径。
  • HIGH 文档漂移:明文值文档写的是 "the username" 但代码实际输出 <trust>:<subject>;JWT claims 列表漏掉了 trust/channel_type;缺少 webchat/IM 渠道的警告说明。已修复中英文双版本 + Python 示例。
  • MED 文档 sub 语义写错(声称是 requesterId;代码实际用不可变数字 id requesterUserId)。
  • MED 进度路径的身份注入(ProgressAwareMcpToolCallback 绕过了正常 call()零行为测试。已补充明文/token/cron 场景测试。
  • LOW NONE 分支覆盖、快路径/轮换/并发压力测试、audienceFor(null,null) 碰撞出 "null" audience。
  • 新增的并发测试捕获了第一版轮换草案中的回归(快路径 null 短路在竞争条件下解析到一半返回空)——已通过将 null 返回决策移入 synchronized 块内修复。

第 2 轮 — 健壮性 / 测试质量 / 集成(14 项修复)

  • HIGH 合法→非法 PEM 轮换在每次调用时都重新解析 + 重新打 ERROR 日志,同时静默保留旧密钥(按 tool-call 频率产生 CPU/日志垃圾)。修复:去重现在覆盖 null-key 和 stale-key 两种情况(Objects.equals + attemptedPem 标志);坏 PEM 只解析/记录一次。
  • MED null/空 PEM 每次调用都打 ERROR 日志。同一修复。
  • MED signingKey() Javadoc 过度承诺了 "config reload / no restart" 的 self-heal 能力——McpIdentityForwardProperties 是普通的 @ConfigurationProperties(不是 @RefreshScope,没有 spring-cloud),所以静态 application.yml 编辑仍需重启。已重写 Javadoc 陈述事实。
  • 测试加固:并发测试现在通过 Logback ListAppender 断言密钥只解析一次(single-flight);非字符串保留键伪造覆盖;call(String) 无上下文 fail-closed;jti 唯一性;exp TTL 有界;self-heal 缓存失败;按 id 的 audienceForforwardsTo OR 语义;类型化 Mockito 匹配器;含冒号的 visitorId 往返;描述性 .as() 消息。

第 3 轮 — 回归/交互 + 生产就绪(9 项修复)

  • CRITICAL .env.example 中对该功能 / MCP_IDFWD_PRIVATE_KEY_PEM 零引用——运维人员复制 .env.example 永远不会知道该功能的存在;token 模式会永远静默 fail-closed。已添加带说明的配置块(与 JWT_SECRET 风格一致)。
  • HIGH docker-compose.yml 没有把 MCP_IDFWD_PRIVATE_KEY_PEM 传入容器——Docker 环境下 token 模式无法工作。已添加透传。
  • HIGH 出厂 application.yml 没有 identity-forward 配置块(只有 mcp.enabled: true);文档与出厂配置不一致。已添加注释示例块,使出厂配置自文档化,$${MCP_IDFWD_PRIVATE_KEY_PEM:} 占位符实际存在。
  • MED 日志注入:Jackson 的 e.getMessage() 被原样插值;精心构造的含 CR/LF 的 toolInput 可以伪造日志行。已添加 sanitize() 辅助方法 + 测试。
  • MED 时钟偏移未文档化(60s TTL + 漂移会拒绝合法 token)。已补充后端 leeway + NTP 指引(中英文)。
  • MED 密钥轮换会静默破坏验签(无 JWKS)。已添加 4 步轮换操作手册(先更新后端)。
  • LOW 加密 PKCS#8 和 FIPS/受限 provider 密钥现在会得到可操作的错误提示。
  • 测试:删除了无用的 captured 变量;用 assumeTrue 保护 logback 强转。

第 3 轮回归审查显式验证了 signingKey() 状态机(重写两次)的全部 9 种状态转换,确认正确——R1/R2 的改动未引入生产回归。

验证

身份透传子集:  23(基线)→ 41(R1)→ 47(R2)→ 48(R3)测试,0 失败
更广泛回归:    287 测试,0 失败(Mcp + ChatOrigin + ToolExecution + RuntimeContext + Channel + Webchat)
YAML 验证:     application.yml、docker-compose.yml 可解析;Spring 可加载 application.yml
压力测试:      log-appender + 并发测试在 5x / 3x 重跑中稳定

JDK 21,mvn -pl mateclaw-server test -Dtest='...'

有意推迟的项(附理由)

  • audienceFor 返回 Optional<String> 而非抛异常——抛异常是正确的 fail-fast,且 Javadoc 文档化了生产路径保证;改动唯一调用方的价值不大。
  • web 分支的 ThreadLocal 用户名回退——改为 fail-closed 会破坏已文档化的遗留路径(talk-mode WebSocket、未来的 web 站点)。保留为向后兼容垫片。
  • privateKeyPem 上加 @JsonIgnore——Spring Boot 默认的 SanitizingFunction 已自动遮蔽它(属性路径含 "key");此处添加会偏离仓库其他 @ConfigurationProperties 密钥(JWT_SECRET)依赖同一默认值的做法。
  • JWKS 端点——超出本 PR 范围;轮换操作手册已文档化当前的带外模型。

提交记录

  • fix(mcp): round-1 adversarial review of #459 identity forwarding
  • fix(mcp): round-2 adversarial review of #459 identity forwarding
  • fix(mcp): round-3 adversarial review of #459 — deployment + hardening

审查报告(ROUND-1/2/3.md)在审查过程中生成但未提交(它们是 .zcode/ 中的工作笔记)。

本 PR 不关闭任何 issue(issue #459 已通过 #460/#471 关闭)。这是对已合并实现的加固。

Addresses 8 confirmed findings from the first adversarial review pass
on the merged mateaix#460/mateaix#471/mateaix#464 implementation:

- signingKey rotation: a cached key was served forever once parsed, so a
  valid→valid PEM rotation never took effect without a restart (contradicted
  the self-heal Javadoc; dangerous when retiring a suspected-compromised key).
  Now tracks lastSuccessfulPem and re-parses on PEM change.
- requesterUserId drop: the approval-replay and queued-message web-origin
  sites used overloads that lost the immutable user id, so on-behalf-of sub
  fell back to the mutable username typed 'authenticated'. Plumbed
  requesterUserIdOf(auth) into both origins.
- doc drift (en+zh): plaintext value is '<trust>:<subject>' (not 'username');
  JWT carries trust + channel_type claims; sub is the immutable numeric id;
  added an Identity-typing section + webchat/IM caveat; fixed Python examples.
- progress-path identity injection (ProgressAwareMcpToolCallback bypasses the
  normal call()) now has behavioral tests for plaintext/token/no-identity.
- NONE-branch coverage: web+no-user+no-ThreadLocal, non-web+blank-requester.
- fast-path reuse + valid→valid rotation + 16-thread DCL stress tests.
- audienceFor(null,null) now rejects instead of returning a colliding 'null'.

The new concurrency test caught a regression in the first rotation draft
(fast-path null short-circuit returned empty mid-parse under contention);
fixed by moving all null-returning decisions inside the synchronized block.

Tests: 216 run, 0 failures (identity-forward subset 23→41).
Addresses 14 confirmed findings from the second adversarial pass
(robustness + test-quality + integration lenses) on the post-R1 code:

signingKey() state machine (R2-1 HIGH, R2-2 MEDIUM):
- valid→invalid PEM rotation re-parsed + re-logged the ERROR on every call
  while silently keeping the stale old key (CPU/log spam, no operator signal).
- null/empty PEM logged the ERROR on every call (dedup couldn't match null).
Both fixed: dedup now covers null-key AND stale-key cases via
Objects.equals(lastAttemptedPem, pem) + an attemptedPem flag distinguishing
first-ever-call from already-tried-null. The bad/empty PEM is parsed/logged
exactly once; a prior good key keeps serving (fail-soft).

Javadoc accuracy (I-1 MEDIUM):
- signingKey() claimed self-heal on 'config reload / no restart', but the
  properties bean is plain @ConfigurationProperties (not @RefreshScope, no
  spring-cloud) so a static application.yml edit does NOT take effect without
  a restart. Rewrote the Javadoc to state the truth.

Test hardening (TQ-1/3/4/5 HIGH, TQ-6/7/8 MEDIUM, TQ-9/10/13 LOW, R2-3 LOW):
- concurrency test now asserts the key is parsed EXACTLY once (single-flight)
  via a Logback ListAppender, not just 'all tokens verify'.
- non-string reserved-key spoof (number/object/array) overwrite covered.
- call(String) with no ToolContext proven fail-closed (no identity injected).
- jti uniqueness across mints asserted (replay distinguishability).
- JWT exp bounded to ttlSeconds window, not just 'after now'.
- self-heal test now proves the bad-PEM failure is cached (not retried/cleared).
- audienceFor by numeric id + name-precedence covered.
- forwardsTo OR-semantics (id OR name) covered across all 4 combinations.
- progress-path tests tightened to any(CallToolRequest.class).
- colon-bearing visitorId plaintext round-trip pinned.
- descriptive .as() messages on load-bearing assertions.

Tests: 204 run, 0 failures (identity-forward subset 41→47).
…rdening

Addresses 9 confirmed findings from the third adversarial pass
(regression/interaction + production-readiness lenses) on the post-R2 code.

Deployment artifacts (P3-1 CRITICAL, P3-2/P3-3 HIGH) — the feature could NOT
be deployed via the documented Docker path before this:
- .env.example: added MCP_IDFWD_PRIVATE_KEY_PEM block (was absent; JWT_SECRET
  was the precedent). An operator copying .env.example never learned the feature
  existed, so token mode silently failed-closed forever.
- docker-compose.yml: pass MCP_IDFWD_PRIVATE_KEY_PEM into the container.
- application.yml: added a commented identity-forward example block so the
  shipped config is self-documenting and the ${MCP_IDFWD_PRIVATE_KEY_PEM:}
  placeholder actually exists (was only in docs/, which disagreed with the
  shipped config).

Hardening:
- Log injection (P3-5): e.getMessage() from Jackson was interpolated raw into
  logs; a crafted toolInput containing CR/LF could forge log lines. Added a
  sanitize() helper (strip control chars, cap length) on all 3 message sites +
  a test pinning the behavior.
- Encrypted/FIPS key errors (P3-8/P3-9): detect ENCRYPTED PRIVATE KEY and
  NoSuchAlgorithmException specifically, emit actionable hints instead of a
  generic 'failed to parse'.

Docs (en + zh):
- Clock skew (P3-6): document backend-side leeway / NTP guidance (60s TTL +
  drift was rejecting valid tokens, undocumented).
- Key rotation runbook (P3-7): rotation is a coordinated multi-host operation
  (update backends FIRST, then MateClaw, watch the load log); no JWKS yet.

Test quality:
- R3-2: deleted a dead 'captured' variable that misled about the assertion sink.
- R3-3: guarded the logback Logger casts with assumeTrue so a non-logback SLF4J
  binding skips instead of throwing ClassCastException.

The signingKey() state machine (rewritten twice across R1/R2) was verified
correct across all 9 transitions by the regression reviewer; no production
regression found.

Tests: 204 run, 0 failures (identity-forward subset 47→48). YAML validated;
Spring loads application.yml; log-appender tests stable across stress runs.
@ncw1992120 ncw1992120 changed the title fix(mcp): 3-round adversarial review of #459 identity forwarding (22 findings fixed) fix(mcp): #459 身份透传功能 3 轮对抗式审查(修复 22 项发现) Jul 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant