MCP OAuth 主机钉接

当 LibreFang 完成对某个 MCP 服务器的 OAuth 授权时,它要把授权码 POST 到的 那个 URL,是从该服务器的发现文档(/.well-known/oauth-authorization-server) 里读出来的。这份文档属于"攻击者可影响的数据":一份被篡改或被恶意托管的 metadata 可以把 token 交换重定向到运营者从未授权过的主机,从而泄露授权码或 最终的 access token。

LibreFang 会把 token 交换的目标钉到运营者在 config.toml亲手输入的 那个主机上。钉接策略只接受两种形态,其它一律拒绝并在守护进程日志里抛出安全 错误,授权流程随之失败。

接受规则

规则接受条件历史
1token 端点的主机与 config.toml 里输入的主机完全一致(忽略大小写)。最初的严格钉接(#3713)。
2token 端点的主机与输入的主机共享同一个可注册域(eTLD+1)——通过 Public Suffix List 计算。#4665 引入,用于支持跨域 OAuth 代理。

规则 2 是对称的——同一个可注册域下的 sub→root、root→sub、sibling→sibling 都会被接受。信任边界是可注册域本身,不是它的层级关系。

为什么需要规则 2

有不少 MCP 服务在设计上就把发现端点和 token 端点拆到同一个可注册域下的两个 不同主机:

  • Slack —— mcp.slack.com 在 metadata 里声明 token 端点为 https://slack.com/api/oauth.v2.user.access
  • Notion —— mcp.notion.com 把 token 交换委派给 api.notion.com

如果没有规则 2,这些合法的流程会被拒绝,运营者除了放弃集成之外没有任何 变通办法。

威胁权衡

规则 2 接受了一类攻击场景:只要攻击者既能控制该可注册域下的任意一个兄弟 子域,又能篡改经过 HTTPS 校验的 metadata,就有可能把 token 交换重定向到 他自己的主机。我们接受这一残余风险的理由是:

  1. metadata 的拉取走 HTTPS 校验,篡改要么需要攻陷合法授权服务器的 HTTPS 端点,要么需要对守护进程的 TLS 实施可用的 MITM——两者都比 DNS 投毒的 门槛高得多。
  2. 在一个组织自己的可注册域下接管兄弟子域,通常已经意味着该组织本身被 攻陷了。最现实的一类风险——指向被删除的第三方服务的悬空 DNS 记录—— 是组织治理的边界,而不是这条检查能挡的。
  3. 严格钉接对合法的跨域 OAuth 委派没有留任何逃生口,导致运营者必须放弃 原本信任的服务。

完整的推理保留在 crates/librefang-api/src/routes/mcp_auth.rstoken_endpoint_host_matches 的文档注释里,以及更深入的 docs/architecture/mcp-oauth-host-pinning.md

多租户主机如何被保护

Public Suffix List 有一个 private 区段,列出了多租户托管的边界—— *.github.io*.herokuapp.com*.s3.amazonaws.com*.vercel.app 等等。对这些主机,PSL 会把每个租户视为各自独立的可注册域。也就是说 两个 GitHub Pages 租户共享可注册域,规则 2 不会接受跨租户的重定向。 这条性质是让"放松到 eTLD+1"这件事在租户边界与 DNS 子域不重合的服务上 依然站得住的关键。

回归测试 token_endpoint_psl_private_domain_does_not_false_match 把这条 性质钉了下来,以防将来 PSL crate 被替换时悄悄回归。

IP 字面量主机

localhost、IPv4 字面量和 IPv6 字面量都不是带已知公共后缀的 DNS 名字, PSL 对它们没有任何观点。代码在进入规则 2 之前会短路 IP 字面量 (IPv6 来自 url::Url 时是带方括号的,先脱掉方括号再解析),也就是说 只有规则 1——完全相等——能接受 IP 字面量的配对。即使两个 IPv4 在尾部 共享 label,例如 10.0.0.1127.0.0.1,也不会通过规则 2 互认。

检查触发时

被拒绝的请求在守护进程日志里长这样:

ERROR mcp_auth: token_endpoint host failed both exact and same-eTLD+1 checks
  vs authorization server refusing token exchange (possible metadata-tamper
  attack; refs #3713 #4665)
  server=<name> token_endpoint=<url> issuer_host=<host> token_host=<host>

授权流程会被中止,该 MCP 服务器在守护进程内的 McpAuthState 会变为 Error,Dashboard 会显示"token_endpoint host mismatch"错误。授权码 不会被 POST 到任何地方。

如果一个你信任的服务被这条规则挡住:

  1. 比对 issuer_host(你输入的主机)和 token_host(metadata 声明的 主机)。如果它们共享你认得的可注册域,通常是该供应商的 metadata 文档配置错了——给他们提个 issue。
  2. 如果 token_host 在一个完全不同的可注册域上,不要绕过这条 检查——这正是钉接被设计来挡掉的 metadata 篡改场景。

不在本策略覆盖范围内

  • metadata 与 token 拉取本身的 SSRFlibrefang-runtime::mcp_oauth::is_ssrf_blocked_url 单独负责 (loopback / link-local / 内网段黑名单)。它走自己的主机检查、有不一样的 威胁模型,没有也不会被迁到 eTLD+1 规则上。
  • 授权端点(authorization_endpoint)的主机 不被本辅助函数钉接—— 它是面向用户的导航目标,不是凭据接收者。
  • 按服务器粒度的 oauth.strict_host_pin = true opt-in 给极度偏执 的运营者用,作为后续工作跟进,本次没有交付。