返回 AICAP-180
B7 · Day 64OAuth 2.1 + MCP 安全 + CI gate

把校验接到 MCP 工具网关

D63 让 JWT 校验内核 happy-path 绿了,但「能验 token」≠「网关真的拦得住越权」。今天解决点位问题:在哪一行代码做拦截?答案是「事中拦截」(in-band enforcement)——在 toolRegistry 真正派发工具调用之前,每次 tool call 都重做 token + scope 双校验。这是 B7 把 D61 的 audience 模型、D62 的三类攻击

阶段: B7 · OAuth 2.1 + MCP 安全 + CI gate(Day 61-70) 标签: #in-band-enforcement #least-privilege #mcp-gateway #per-call-scope

今日导引(由浅入深)

D63 让 JWT 校验内核 happy-path 绿了,但「能验 token」≠「网关真的拦得住越权」。今天解决点位问题:在哪一行代码做拦截?答案是「事中拦截」(in-band enforcement)——在 toolRegistry 真正派发工具调用之前,每次 tool call 都重做 token + scope 双校验。这是 B7 把 D61 的 audience 模型、D62 的三类攻击、D63 的校验内核合流到执行面的一天。最小可判定产出:guard 接入后测试套件全绿 + server.ts 实测「无 token→401,合法 token→200」,整体 423 tests green。

1. 机理精读

事中拦截 vs 入口鉴权:点位决定安全性。 传统 API gateway 模式是「入口验一次 token,进了内网就全信任」。这个模式在 MCP/agent 场景会漏:一次会话里,agent 可能连续调多个工具;如果只在会话入口验一次,那一个 token 就解锁了全部工具——但 token 的 scope 可能只覆盖其中一部分。攻击/失误场景:一个只该读证据的 agent,token 只含 aml:read,却在会话中途调了 sar.draft(需 aml:write)。入口鉴权放过它,因为入口只检查「有没有有效 token」,不检查「这次具体调的工具配不配它的 scope」。

正确点位:toolRegistry 真正 dispatch 工具之前。 每次 tool call 都重新做两件事:

  1. token 校验verifyAccessToken):签名/过期/iss/aud 还有效吗?
  2. scope 比对:本次调用的工具所需 scope ⊆ token 携带的 scope?缺则拒。

这叫 per-call scope 校验,是最小权限(least privilege)在执行面的落点。最小权限不只是「发 token 时给少」(D61 的 scope 收敛),更是「每次执行时核」——发得再窄,执行时不核也白搭;执行时核得再严,发得太宽也漏。两者是一对,今天补的是后者。

为什么「单 token 不应解锁全部工具」是硬约束。 agent 会话是长程的、多步的,中途可能被 prompt injection 诱导去调它本不该调的工具(这是 OWASP LLM Top 10 的核心风险)。如果一个 token 入口验过就全程通行,那么一次注入就能让 agent 把它全部 scope 之外的工具也调遍——因为根本没有 per-call 这道闸。per-call scope 校验把每次调用都变成独立的授权决策,注入最多只能调到 token scope 内的工具,爆炸半径被 scope 收敛锁死。

与 schema 校验的边界(再钉一次)。 dispatch 前其实有两道闸:schema 校验(防畸形参数,-32602)和 OAuth guard(防越权,401/403)。它们正交、不可互相替代——参数格式完美但 scope 不足的调用,schema 放行、guard 拦截;参数畸形但 scope 充足的调用,guard 放行、schema 拦截。两道都要在 handler 执行前跑完。

2. 推导 / 手算 / 代码走读

走读真实接入点 src/agent/mcp/server.tsstartHttpServer(port)(这是本仓 OAuth gate 的真实落地处):

  • gate 是可选启用的:const secret = process.env.MCP_AUTH_SECRET。本地 dev 默认不设 secret → 不鉴权(开发便利);设了 secret → 启用。这对应 seed 说的「server.ts 由 env MCP_AUTH_SECRET 门控是否启用鉴权」。
  • 启用后,每个 /mcp 请求在解析 body 之前先过 gate:
    1. const token = bearerFromHeader(req.headers['authorization'])if (!token)res.writeHead(401, { 'WWW-Authenticate': 'Bearer' })(无 token → 401,D65 展开语义)。
    2. const claims = await verifyAccessToken(token, { secret })(D63 的内核)。
    3. requireScope(claims, 'mcp:call')调用前 scope 校验:没有 mcp:call scope 就抛,被 catch 接住返回 401 + WWW-Authenticate: Bearer error="invalid_token"
  • 安全细节:catch 块注释明确 "Opaque body — do NOT echo the jose error (would be an expiry/signature oracle)"——不回显 jose 的具体错误,否则攻击者能从「是过期还是签名错」推断 token 状态(oracle 攻击)。统一回 invalid_token
  • gate 通过后才 createMcpServer() + StreamableHTTPServerTransport 处理真 JSON-RPC(stateless:每请求新建 server+transport)。

诚实标注:seed 描述的是「升级 toolRegistry.ts 在 dispatch 前插 guard」。本仓的真实形态略有不同——OAuth gate 落在网络层 server.ts(真 MCP server 的 HTTP 入口),而进程内教学装置 toolRegistry.tscall/handle 仍只做 schema 校验(validate),没有内嵌 OAuth guard。也就是说本仓的「事中拦截」实现是请求级(每个 /mcp HTTP 请求过 gate),通过 mcp:call 这个粗 scope 门控整个 server;seed 设想的「每工具不同 scope 的 per-tool 校验」是更细的形态,当前以 mcp:call 单 scope 落地,per-tool scope 矩阵(D61)尚是设计层。不要把 toolRegistry.ts 说成「已内嵌 OAuth guard」——它没有。

手推无 token 路径

请求无 Authorization 头 + MCP_AUTH_SECRET 已设
  → bearerFromHeader(undefined) === null
  → writeHead(401, {'WWW-Authenticate':'Bearer'}).end('missing bearer token')
  → 不进入 createMcpServer,工具不被派发 ✓

合法 token 路径

Authorization: Bearer <mint 的含 mcp:call 的 token>
  → bearerFromHeader → token
  → verifyAccessToken(token,{secret}) → claims ✓
  → requireScope(claims,'mcp:call') 不抛 ✓
  → createMcpServer + transport.handleRequest → 200 + JSON-RPC 结果 ✓

3. 今日实战

  1. src/agent/mcp/server.tsstartHttpServer 请求处理里,body 解析前插入 OAuth gate:bearerFromHeaderverifyAccessTokenrequireScope(claims, 'mcp:call'),缺则 401。
  2. process.env.MCP_AUTH_SECRET 门控启用(默认 off,本地 dev 不鉴权)。
  3. src/agent/__tests__/mcp/server.test.ts 确认真协议路径全绿;本地 MCP_AUTH_SECRET=x pnpm mcp:serve 后 curl 无 token(401)与带 mint token(200)各验一次。
  4. 跑全量 pnpm test 确认 423 tests green。

4. 今日实测 / 产出

  • guard 接入后 toolRegistry/server 测试套件全绿(N green, 0 fail)已完成
  • server.ts 已实测:无 token→401,mint 的合法 token→200已完成
  • 整体 423 tests green已完成
  • 诚实标注:当前事中拦截以请求级 mcp:call 单 scope 落地(server.ts);per-tool 细粒度 scope 校验(D61 的 5×scope 矩阵)仍是设计层,待建

事中拦截 vs 出带外授权的对照

维度入口鉴权一次(out-of-band/旧 gateway)事中拦截(in-band/per-call)
校验时机会话/连接建立时 1 次每个 tool call / 每个请求
中途越权挡不住(token 全程通行)挡得住(每次重核 scope)
注入爆炸半径全部工具≤ token 携带的 scope
成本低(验 1 次)略高(每次验签 + 比 scope)
适用内网可信的单体零信任 / agent 多步会话

本仓选事中拦截,因为 agent 是长程多步的——成本那点开销换的是「单次注入打不穿 scope 边界」。

本仓 gate 当前做的(诚实边界):① per-tool 细粒度 scope(现在是 mcp:call 单 scope 门整个 server);② token exchange 调下游(本 server 不调下游,无 passthrough 面);③ RFC 9728 metadata 端点(生产形态,待 07-28 spec)。这些是 B7 后续/生产迁移项,不在今天的 423 绿测试覆盖内。

5. 常见误区 / 陷阱

  1. 入口鉴权一次后内部全信任(旧 API gateway 模式):会话中途越权调用挡不住;必须 per-call/per-request 校验。
  2. gate 放在 body 解析之后:先解析再鉴权,等于让未授权请求消耗解析/反序列化资源(DoS 面);gate 应在最前。
  3. 回显 jose 错误:把「过期/签名错」原文返回 → 给攻击者一个 token 状态 oracle;统一回 opaque invalid_token
  4. 默认开启鉴权却忘了配 secret:本仓默认 off(无 secret 即不鉴权)是 dev 便利,但生产部署必须MCP_AUTH_SECRET,否则 server 裸奔。

6. 学习资源(每条带 YYYY-MM)

  • MCP Security Best Practices(modelcontextprotocol.io,2026-01)— per-request 重授权、scope 双校验。
  • OWASP Top 10 for LLM Applications(OWASP,2025)— prompt injection 驱动的越权工具调用。
  • RFC 6749 OAuth 2.0 Authorization Framework(IETF,2012-10)— scope 模型。
  • NIST Zero Trust Architecture(SP 800-207,NIST,2020-08)— 「永不信任、持续验证」的执行面原则。
  • 本仓代码:src/agent/mcp/server.tsstartHttpServer OAuth gate)+ src/agent/mcp/auth.ts(2026-06)。

SOTA检查 (2026-06 更新)

  • 当前主流:per-call(本仓为 per-request)scope 校验 + 最小权限在执行面落点,符合 MCP 2026-01 最佳实践
  • 是否仍 SOTA:是。待 2026-07-28 spec 定稿复查是否要求 RS 暴露 metadata 端点(影响 client 发现 + 可能要求更细 scope 元数据)。
  • 过时黑名单:避免「网关入口鉴权一次后内部全信任」的旧 API gateway 模式;避免在 body 解析后才鉴权;避免回显验证错误细节(oracle)。
  • 下次复查点:2026-07-28 spec 定稿当周,评估是否把 per-request mcp:call 单 scope 升级为 per-tool 细粒度 scope(落地 D61 矩阵)。

衔接

  • 昨天:Day 63 — JWT 校验内核(jose),happy-path 绿。
  • 今天:把校验接到工具网关请求入口(事中拦截),无 token→401 / 合法 token→200,423 tests green。
  • 明天:Day 65 — 拒绝路径与错误语义(WWW-Authenticate:401 vs 403)。