把校验接到 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 都重新做两件事:
- token 校验(
verifyAccessToken):签名/过期/iss/aud 还有效吗? - 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.ts 的 startHttpServer(port)(这是本仓 OAuth gate 的真实落地处):
- gate 是可选启用的:
const secret = process.env.MCP_AUTH_SECRET。本地 dev 默认不设 secret → 不鉴权(开发便利);设了 secret → 启用。这对应 seed 说的「server.ts由 envMCP_AUTH_SECRET门控是否启用鉴权」。 - 启用后,每个
/mcp请求在解析 body 之前先过 gate:const token = bearerFromHeader(req.headers['authorization']);if (!token)→res.writeHead(401, { 'WWW-Authenticate': 'Bearer' })(无 token → 401,D65 展开语义)。const claims = await verifyAccessToken(token, { secret })(D63 的内核)。requireScope(claims, 'mcp:call')— 调用前 scope 校验:没有mcp:callscope 就抛,被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.ts的call/handle仍只做 schema 校验(validate),没有内嵌 OAuth guard。也就是说本仓的「事中拦截」实现是请求级(每个/mcpHTTP 请求过 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. 今日实战
- 在
src/agent/mcp/server.ts的startHttpServer请求处理里,body 解析前插入 OAuth gate:bearerFromHeader→verifyAccessToken→requireScope(claims, 'mcp:call'),缺则 401。 - 用
process.env.MCP_AUTH_SECRET门控启用(默认 off,本地 dev 不鉴权)。 - 跑
src/agent/__tests__/mcp/server.test.ts确认真协议路径全绿;本地MCP_AUTH_SECRET=x pnpm mcp:serve后 curl 无 token(401)与带 mint token(200)各验一次。 - 跑全量
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. 常见误区 / 陷阱
- 入口鉴权一次后内部全信任(旧 API gateway 模式):会话中途越权调用挡不住;必须 per-call/per-request 校验。
- gate 放在 body 解析之后:先解析再鉴权,等于让未授权请求消耗解析/反序列化资源(DoS 面);gate 应在最前。
- 回显 jose 错误:把「过期/签名错」原文返回 → 给攻击者一个 token 状态 oracle;统一回 opaque
invalid_token。 - 默认开启鉴权却忘了配 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.ts(startHttpServerOAuth 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)。