KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

07 · 授权和本地服务器为什么不能省? — keel 龙骨

## 现场:一张给隔壁部门签的票,在我们的柜台上兑现了

现场:一张给隔壁部门签的票,在我们的柜台上兑现了

公司里有两个 MCP Server:incident-bridge(事件桥接)和 runbook-server(运维手册)。它们共用同一个内部签发服务——这在内部系统里很常见,「都是自家服务,何必搞两套」。

某天安全评审提出一个问题:如果有人拿到发给 runbook-server 的凭据,能不能直接拿到 incident-bridge 上用?

工程师第一反应是不会,「token 不一样」。实际测试的结果是:能。

原因是那些 token 只声明了「谁(subject)、哪个租户、什么时候过期、有什么 scope」,唯独没有声明**「签给谁的」**。只要签名密钥相同,runbook-server 读者的票据就能在 incident-bridge 上兑现出同等权限。

这就是经典的 confused deputy(困惑代理人):一个持有合法凭据的调用方,说服了服务端相信「这个凭据是给我的」。服务端没有检查自己是不是那个被授权的接收方。

直觉模型:发现不等于允许

第 04 章说过「清单不等于权限」,本章把这句话推到授权环节。两条常被混淆的界线:

说法 实际情况
「Server 声明了这个工具,所以它可以被用」 Server 声明的是它提供什么;能不能用由策略和凭据决定
「凭据有效,所以可以调用」 有效不等于签给本站;还要检查受众、租户、scope

第一列是第 04 章的清单过滤,第二列是本章的凭据校验。两条都用 issubset 语义判定,共用同一个判定函数。

精确定义:什么时候必须有 OAuth

规范对「授权怎么接」的约束是按传输分的(来源:modelcontextprotocol.io 授权章节,检索于 2026-09-29):

传输 要求
HTTP 类传输 SHOULD 遵循 OAuth 授权规范
stdio SHOULD NOT 遵循该规范,改为从环境读取凭据
其他传输 MUST 遵循该传输既有的安全最佳实践

第 06 章提过这条,这里补它的理由——机制要匹配威胁模型,不是越多越好。

stdio 的场景里,进程由客户端亲自拉起,操作系统已经给出了身份边界(谁启动的、文件系统权限是谁的)。在 OS 已经给出强边界的地方再叠一层 OAuth,不增加安全性,只增加复杂度和失败模式。这条适用前提很硬:进程确实是自己启动的、确实在本机。

一旦这两个前提任意一个不成立(远端部署、多租户共用、进程由别的主体拉起),就必须切换到 HTTP 那套。第 06 章现场那次「搬到远端后谁都能连」的事故,就是前提失效了却没换机制。

HTTP 授权的角色划分

真接 OAuth 时,角色是这样的:

┌────────────┐  ① 发现受保护资源元数据(RFC 9728)  ┌──────────────────┐
│ MCP Client │ ────────────────────────────────────► │   MCP Server     │
│            │                                       │ (资源服务器 RS) │
│            │  ② 携带资源指示器去申请 token(RFC 8707)│                  │
│            │ ─────────────────────────────────────►│ 授权服务器 AS     │
│            │ ◄───────────────────────────────────── │                  │
│            │  ③ 拿到绑定了 audience 的短期 token     │                  │
│            │ ─────────────────────────────────────►│ RS 校验 aud/iss/  │
│            │  ④ 带着 token 调 tools/call            │ 过期/scope        │
└────────────┘                                       └──────────────────┘

三个 RFC 级别的要求,缺一个就会给 confused deputy 留缝:

  1. RFC 9728 受保护资源元数据:客户端要能发现「这个资源由哪个 AS 保护」。
  2. RFC 8707 资源指示器:申请 token 时要带上目标资源的规范 URI,让 token 里带上 aud(audience)。
  3. 受众校验:资源服务器必须验证 token 的 aud 是不是自己。

第 3 条就是本次事故的防线。签发环节用不用资源指示器都没用,只要校验环节不看 audience 就白搭。

一次完整运行

python courses/foundation/mcp-protocol-engineering/course/project/examples/06_auth.py

实跑输出(为可读性做了换行):

expired token rejected: expired token
{'content': [{'type': 'text', 'text': 'payments: 2 个已授权事件摘要'}],
 'structuredContent': {'service': 'payments', 'count': 2, 'tenant': 'acme'}}

第一行的 ttl_seconds=-1 制造了一个早已过期的 token。这段替身的价值值得说清楚:HMAC bearer 的好不在于它多像 OAuth,而在于它把「过期」「受众」「scope」「租户绑定」这四个检查变成了确定性测试——不需要起 IdP,不需要联网,一毫秒就能跑完。

对照一下就明白这类替身的意义:换成真实 OAuth,想验证「过期是否被拒」,你得准备一个 IdP、等到 token 过期、或者伪造时钟。而在这里,ttl_seconds=-1 一行就够了。教学替身的目标不是仿真,是让不变量可测。

失败注入:六种越权,一个都不能通过

下面这段代码把六种常见越权各打一遍。它是可以直接跑的:

full = issue_token(server.secret, "alice", "acme", {"incident:read", "service:read"})

attempt("A 跨租户",   lambda: server.call_tool("incident.search", {"service": "payments"}, full, "globex"))
attempt("B 缺 scope", lambda: server.call_tool("incident.search", {"service": "payments"},
                        issue_token(server.secret, "bob", "acme", {"service:read"}), "acme"))
attempt("C 签名篡改", lambda: server.call_tool("incident.search", {"service": "payments"}, full[:-4] + "dead", "acme"))
attempt("D 未知工具", lambda: server.call_tool("ticket.create", {"title": "x"},
                        issue_token(server.secret, "bob", "acme", {"ticket:write"}), "acme"))
attempt("E 过期令牌", lambda: server.call_tool("incident.search", {"service": "payments"},
                        issue_token(server.secret, "bob", "acme", {"incident:read"}, ttl_seconds=-1), "acme"))

实跑输出:

A 跨租户 -> 拒绝: PermissionError: tenant mismatch
B 缺 scope -> 拒绝: PermissionError: missing scope
C 签名篡改 -> 拒绝: PermissionError: bad signature
D 未知工具 -> 拒绝: KeyError: 'unknown tool: ticket.create'
E 过期令牌 -> 拒绝: PermissionError: expired token

每种拒绝都有专属原因,这一点很重要:一律回答 401 Unauthorized 会让排查的人失去全部线索,让监控失去分类能力。拒绝原因要能区分,但要小心不要反过来成为信息泄露(下面会讲)。

注入 F:confused deputy

# 同一个签发密钥,但 audience 是隔壁的 runbook-server
forged = issue_token(server.secret, "alice", "acme", {"incident:read", "service:read"},
                     audience="https://mcp.acme.internal/runbook-server")

实跑输出:

F 错受众 -> 拒绝: PermissionError: audience mismatch
G 正确受众 -> 成功: {'content': [{'type': 'text', 'text': 'payments: 2 个已授权事件摘要'}], 'structuredContent': {...}}

F 这行是本项目在本次修订中补上的防线。 修改之前,token 负载长这样:

payload = f"{subject}|{tenant}|{expires_at}|{','.join(sorted(scopes))}"

签名有效、未过期、租户对、scope 全——四项全过,唯独没有回答"这张票是签给谁的"。现在 audience 进了被签名的负载:

payload = f"{subject}|{tenant}|{expires_at}|{audience}|{','.join(sorted(scopes))}"

并且校验环节强制比对:

if token_audience != audience:
    raise ValueError("audience mismatch")

一个值得记住的细节:audience 必须在被签名的字符串里。 如果它只作为未签名的载荷字段传过去,攻击者改一下就绕过去了。

一个必须承认的教学简化

DEFAULT_AUDIENCE = "https://mcp.acme.internal/incident-bridge"

本项目用了一个模块常量做默认受众,好处是八个示例不用到处传参数。生产不能这么干:audience 应该是每个 Server 的规范 URI,由配置注入,服务端显式校验。用一个共享常量,等于把「受众绑定」简化成了「所有站点互相接受」——正好退化回本次事故的原点。

本地服务器为什么同样危险

常见的错误想法是「stdio 是本地的,所以安全」。实际情况是:stdio 进程拥有宿主的权限。

它启动的时候继承了:当前工作目录、全部环境变量、文件系统权限、网络出口。也就是说一个「本地」MCP Server 可以读你的 ~/.aws/credentials、可以发起外网请求、可以在工作目录里写文件。

所以本地服务器至少要锁:

项目 要求
启动路径 固定或白名单,不允许拼接用户输入
命令行参数 白名单或 schema 校验
工作目录 显式指定,不要继承 shell 的 cwd
环境变量 显式最小集,不要把整个环境传进去
敏感输出 不能因为是「本地」就跳过脱敏与审计

再加一条和 confused deputy 呼应的:多个本地 Server 不要共用同一个凭据来源。 那就相当于把所有 Server 合并成一个权限域。

必须记录的审计字段

拒绝要留痕。一次授权相关的事件至少要记录:

主体、租户、server、tool、resource、scope、policy decision(allow/deny + 原因码)、request id、trace id、结果类别、耗时。

参数必须脱敏,尤其是 token、密钥和个人数据。本项目每个事件都带 trace_id:

def record(self, name: str, **attributes: Any) -> str:
    trace_id = attributes.pop("trace_id", uuid.uuid4().hex)
    self.events.append({"name": name, "trace_id": trace_id, "at": time.time(), **attributes})
    return trace_id

注意这个函数返回 trace_id,让调用方可以继续往下游传递。这是能把一次 Agent 运行串起来的关键。

回到「拒绝原因要不要细」这件事:详细原因应该进审计(内部可见),而不一定进响应(返回给调用方)。区分两者的常见做法:响应里给稳定的原因码(如 missing_scope),审计里给完整上下文。

生产替换点

教学实现 生产替换
HMAC bearer + 共享密钥 OAuth 2.1 + 短期 token + JWKS 轮转 + 租户策略
模块常量 DEFAULT_AUDIENCE 每个 Server 的规范 URI,配置注入,服务端显式校验
代码里写死 scopes 策略引擎按租户/环境/时段计算
列表式事件 OpenTelemetry spans/metrics/logs + SIEM
无 JWKS 轮转 密钥轮换与撤销清单,含失效传播时延监控

练习与验收

练习(有可观察结果):把 DEFAULT_AUDIENCE 换成两个不同的值,分别给两个 MCPServer 实例,写测试证明「A 的票不能在 B 上用、B 的票也不能在 A 上用」。

验收标准:两个方向都必须抛 audience mismatch。只有一个方向通过,说明你只改了签发没改校验。

本章检查点

现在能解释什么

你现在能解释那次跨部门凭据事故的完整机制:token 缺 aud → 校验环节不检查受众 → 同密钥签发的任意票据都能兑现。你也知道三个 RFC 级别的防线(9728 / 8707 / 受众校验)各自防什么,以及为什么缺一个就漏。本地服务器部分你知道「本地」不等于安全,它继承的是宿主的全部权限。下一章处理时间维度的问题:任务要跑二十分钟,进程在第七分钟崩了,怎么办。

进入 keel 阅读