MCP 自定义信任,别给钓鱼留门

允许用户接入自建 MCP 客户端,不等于放开任意 OAuth 回调地址。更稳妥的方案是把动态注册与信任授权拆开,并将重定向 URI 精确绑定到用户、客户端和资源。
近期,StreamVessel 的一篇工程文章把 MCP 生态中一个容易被低估的问题摆上了台面:平台怎样允许用户信任自己的 MCP 客户端,同时又不把 OAuth 重定向地址变成钓鱼入口。
这个问题正在变得现实。远程 MCP 服务器逐渐进入 IDE、CLI、企业智能体和安全运营平台,服务端既要兼容 Claude Code、VS Code、Devin 一类现成客户端,也要容纳企业自建客户端。最省事的做法是允许客户端通过动态客户端注册提交任意 redirect_uri,但这也等于告诉攻击者:只要做出一个看起来像正常 MCP 授权的链接,就有机会让用户把授权码送到攻击者控制的网站。
截至 2026 年 11 月 4 日,更合理的结论已经很明确:动态注册只解决“客户端如何登记”,不能自动回答“这个客户端是否值得信任”。 MCP 服务端必须把注册、信任、授权和回调校验拆成不同环节。

先说结论:不要把 DCR 当成信任机制
动态客户端注册(Dynamic Client Registration,DCR)是客户端自动向授权服务器登记元数据并获得客户端标识的 OAuth 机制。 它适合 MCP,因为客户端通常无法提前知道用户将连接哪些服务器,服务端也不可能预先内置所有 IDE、桌面应用和企业客户端。
但 DCR 的“动态”不代表服务端应该照单全收。客户端提交的名称、Logo、主页和重定向地址,本质上都是未经验证的输入;如果注册完成后立即获得与官方客户端相同的可信状态,攻击者就能注册一个名称叫“VS Code MCP”或“Company Security Agent”的客户端,再把回调地址指向自己的域名。
OAuth 重定向 URI 是授权服务器完成用户授权后,用来把授权码送回客户端的确定地址。 这个地址一旦被攻击者控制,授权流程本身越顺畅,钓鱼效果反而越好。
MCP 授权规范要求客户端遵循 OAuth 安全实践,并普遍采用 PKCE、授权服务器元数据发现和资源绑定;这些机制非常重要,但它们不能替代客户端信任判断。PKCE 能阻止第三方窃取别人的授权码后直接兑换令牌,却挡不住恶意客户端自己生成 code_verifier、自己发起授权,再诱导受害者点击。
一次完整攻击是怎样发生的
真正危险的场景不是“授权码在网络中被截获”,而是用户从一开始就在给恶意客户端授权。 典型攻击链可以压缩为五步:
- 攻击者通过 DCR 注册一个外观正常的 MCP 客户端,并提交
https://evil.example/callback作为回调地址。 - 攻击者生成自己的 PKCE challenge、
state和授权请求,再把链接包装成“重新连接 GitHub MCP”或“企业助手需要更新权限”。 - 受害者在真实授权服务器页面登录,看到一个名称经过伪装的客户端并点击同意。
- 授权服务器把一次性授权码重定向到攻击者控制的回调地址。
- 攻击者使用自己持有的 PKCE verifier 兑换 MCP 访问令牌,并以受害者身份调用工具或读取资源。
这条链路中,HTTPS、PKCE、state 和短期授权码都可能完全正常。问题出在授权服务器没有回答一个最关键的问题:为什么这个用户应该信任这个回调来源?
state 也不是这类攻击的答案。state 主要用于把授权响应与客户端此前发起的会话关联起来,防止登录 CSRF 和响应串线;恶意客户端当然知道自己生成的 state,因此不会被它拦住。
三类回调策略,安全性差异很大
回调策略的核心差异在于“谁可以声明地址,以及声明后是否立即可信”。 常见方案可以放在同一张表里比较:
| 策略 | 自定义客户端支持 | 钓鱼风险 | 运维成本 | 适用场景 |
|---|---:|---:|---:|---|
| 仅允许平台预置回调 URI | 低 | 低 | 中 | 少数固定合作客户端、封闭企业系统 |
| DCR 可提交任意 HTTPS URI | 高 | 极高 | 低 | 不建议用于承载真实用户数据的生产环境 |
| 按域名后缀放行,如 *.example.com | 中 | 高 | 低 | 仅适合域名管理极严格且无用户子域名的内网 |
| 用户预先登记并确认自己的 URI | 高 | 低至中 | 中 | 企业自建客户端、个人自托管客户端 |
| 域名控制权验证加用户确认 | 高 | 较低 | 较高 | 多租户 SaaS、敏感 MCP 工具 |
| 官方客户端预置,自定义客户端隔离审批 | 高 | 最低 | 较高 | 安全、财务、代码仓库等高权限场景 |
表面上看,通配符是兼顾易用性的办法,实际上它经常只是把漏洞藏进域名结构。一个允许 https://*.company.example/callback 的规则,只有在所有子域名都由同一安全边界控制时才勉强成立;如果公司提供用户主页、预览部署、对象存储网站或可自定义子域名,攻击者仍可能获得一个匹配通配符的地址。
正确设计:让用户在授权流程之外建立信任
用户自定义信任来源是由用户主动登记、验证并授权给特定 MCP 客户端使用的回调地址集合。 关键不在于做一个“添加 URL”输入框,而在于信任动作必须发生在攻击者难以操纵的独立管理面中。
一个可靠的产品流程应该是:用户先进入账户安全设置,添加客户端来源;平台要求重新验证身份,并明确展示协议、主机、端口和路径;验证通过后,系统才把它写入该用户或组织的信任列表。之后,授权端点收到请求时,再检查当前登录用户、client_id、redirect_uri 和目标 MCP 资源是否同时匹配。
信任设置不能在 OAuth 授权页面里一键完成。 如果授权页出现“当前地址尚未信任,点击这里信任并继续”,攻击者只需在钓鱼文案中指导用户多点一次,安全边界就形同虚设。
更稳妥的设计至少要做到以下几点:
- 添加或修改可信 URI 时要求重新认证,敏感系统可进一步要求 WebAuthn、通行密钥或企业 SSO 二次确认。
- 信任管理页面不得接受授权请求传入的返回地址,也不能在保存后自动跳回原始 OAuth 流程。
- 新增信任项时展示完整规范化地址,而不是只显示容易伪装的客户端名称。
- 信任记录绑定到具体用户或组织,不能因为一个租户信任某域名就自动放行全平台用户。
- 信任记录同时绑定
client_id、回调 URI 集合、客户端类型和 MCP 资源,避免一个低权限连接被复用于高权限服务器。 - 所有新增、修改、调用和撤销动作进入审计日志,并向管理员或用户发送通知。
URI 校验必须精确,不能靠字符串“看起来差不多”
URI 规范化是把不同写法转换成可稳定比较的结构化形式,但它不能把原本不同的安全边界合并。 实现时应先使用标准 URL 解析器拆分 scheme、host、port、path、query 和 fragment,再按字段执行策略,而不是使用 startsWith、正则拼接或简单后缀判断。
下面是一份偏策略化的校验流程,它不是 API 调用代码,而是服务端应落实的决策顺序:
收到 OAuth 授权请求
1. 解析 client_id、redirect_uri、resource、scope、code_challenge
2. 拒绝缺少 PKCE S256 的公共客户端
3. 读取客户端注册信息,但不把“已注册”等同于“已信任”
4. 完成用户身份识别,并读取该用户或组织的信任记录
5. 对 redirect_uri 做标准 URL 解析和规范化
6. 按客户端类型执行精确匹配规则
7. 检查信任记录是否绑定当前 client_id 与 MCP resource
8. 在同意页展示真实主机、权限范围和资源名称
9. 签发一次性、短生命周期且绑定 PKCE 的授权码
10. 兑换令牌时再次核对 client_id、redirect_uri、PKCE 与 resource
Web 客户端应使用完整 HTTPS URI 精确匹配。 协议、主机、有效端口、路径和查询参数都应该与登记值一致,URI fragment 不应被接受为服务端回调条件。https://client.example/callback 与 https://client.example/callback/ 是否视为相同,也必须在写入时统一,而不能在授权过程中临时猜测。
域名比较必须防住后缀欺骗。 client.example.evil.com 不是 example.com 的子域名,example.com@evil.com 的真实主机也是 evil.com;国际化域名还需要统一转换并检查 Punycode,避免视觉上相似的字符混淆用户。
回调端点本身不能是开放重定向器。 即使授权服务器精确放行了 https://trusted.example/redirect,如果该页面接受 next=https://evil.example 并无条件跳转,授权码仍可能被带走。平台预置的合作客户端需要进行开放重定向测试,自定义客户端则应在确认页面明确告知用户这一责任边界。
本地客户端与桌面应用要单独处理
Loopback redirect 是桌面或 CLI 客户端在本机临时监听端口接收 OAuth 回调的方式。 它通常使用 http://127.0.0.1:{随机端口}/callback 或 IPv6 回环地址,不应被当成普通公网 HTTP 地址处理。
Loopback 模式可以允许随机端口,但主机和路径仍应受限。服务端应只接受明确的 IP 回环地址,避免把 localhost 解析行为交给不一致的 DNS、代理或 hosts 配置;同时不能把“允许回环地址”扩展成“允许任意内网 IP”。
自定义 URI scheme 是通过类似 myapp://oauth/callback 的地址唤起原生应用。 它部署方便,但普通自定义 scheme 可能被另一款应用抢注;在移动端和桌面端,应优先使用具有关联验证能力的 HTTPS App Links、Universal Links 或平台声明机制。
不同客户端类型不应该共用一条宽松规则:
| 客户端类型 | 推荐回调形式 | 关键限制 |
|---|---|---|
| Web 应用 | https://client.example/oauth/callback | 完整 URI 精确匹配,禁止通配符和开放跳转 |
| 桌面应用、CLI | http://127.0.0.1:{port}/callback | 仅回环 IP,可允许动态端口,必须使用 PKCE |
| 移动端、原生应用 | 经平台验证的 HTTPS Link | 校验应用与域名关联,避免普通 scheme 被抢注 |
| 企业内网客户端 | 企业 HTTPS 域名 | 绑定租户,结合管理员审批与域名控制验证 |
| 开发环境 | 临时回环地址 | 限制作用域、令牌寿命和环境,不能继承生产权限 |
域名验证有用,但不能取代用户确认
域名控制权验证是让客户端证明自己能控制回调域名的机制。 常见做法包括 DNS TXT challenge,或者在 HTTPS 域名的固定 /.well-known/ 路径发布一次性挑战值。
域名验证能排除“随手填入别人域名”的注册,却不能证明运营该域名的人值得用户信任。攻击者完全可以购买一个正常域名并通过所有权验证,因此最终授权页仍需展示来源、权限和目标资源。
服务端主动访问验证地址时还要防 SSRF。验证器应拒绝云元数据地址、私网地址、链路本地地址和异常端口,限制跳转次数、响应大小与超时时间,并在 DNS 解析后及建立连接前再次检查目标 IP,避免 DNS rebinding 把公网域名切换到内网。
对于本机回环地址和自定义 scheme,域名所有权验证并不适用。此时应依靠明确的客户端类型、用户在独立设置页中的确认、较窄权限、短令牌寿命和可撤销会话共同降低风险。
MCP 还有一层容易混淆的授权关系
MCP 服务器是向客户端暴露工具和资源、并验证访问令牌的资源服务器。 按当前 MCP 授权架构,客户端应通过受保护资源元数据和授权服务器元数据发现正确的 issuer、授权端点与令牌端点,而不是根据 MCP URL 随意拼接登录地址。
一些 MCP 服务还会代理 GitHub、Google Workspace、Atlassian 或其他 SaaS,这时系统中实际存在两层 OAuth:第一层是 MCP 客户端向 MCP 服务申请 MCP 令牌,第二层是 MCP 服务作为 SaaS OAuth 客户端获得上游令牌。
两层 OAuth 的令牌必须彼此隔离。 MCP 客户端只应收到面向 MCP 资源的令牌,服务端不能把上游 SaaS access token 直接交给客户端;令牌的 audience 或 resource 也要绑定目标 MCP 服务,避免一个服务器签发的令牌被拿到另一服务使用。
这一点与回调信任直接相关。攻击者如果通过恶意客户端拿到的是权限宽泛、可长期刷新、还能调用多个上游 SaaS 的令牌,单次钓鱼就可能升级成代码仓库、工单系统和企业文档的组合失陷。
同意页要显示“安全边界”,而不是营销文案
OAuth 同意页的任务是帮助用户识别授权对象和权限后果,而不是替客户端做品牌展示。 服务端不应直接信任 DCR 上传的 Logo、名称和描述,更不能让这些字段遮住真实域名。
一个合格的 MCP 授权页至少应显示:
- 客户端的验证状态:平台预置、组织批准、用户自定义或未经验证。
- 实际回调主机与完整来源,例如
agent.company.example,而不只是“企业助手”。 - 将要访问的 MCP 服务器和资源,而不是笼统写“访问你的账户”。
- 具体 scope 及其结果,例如“读取仓库”与“创建或合并 Pull Request”要分开。
- 是否会签发 refresh token,以及会话持续时间。
- 撤销入口和最近一次批准时间。
高风险 scope 不应因为客户端已被用户信任就静默授权。可信回调只说明授权响应会送到用户认可的位置,不代表该客户端可以自动获得所有工具权限。
一套可落地的数据模型
信任记录应该是独立于 DCR 注册记录的安全对象。 推荐至少保存以下字段:
{
"owner_type": "user_or_organization",
"owner_id": "stable_internal_id",
"client_id": "registered_client_id",
"client_type": "web_or_loopback_or_native",
"redirect_uris": ["exact_normalized_uri"],
"resource_ids": ["approved_mcp_resource"],
"verification_method": "admin_or_dns_or_explicit_user_consent",
"status": "active_or_revoked",
"created_at": "timestamp",
"expires_at": "timestamp_or_null"
}
注册记录负责描述客户端声称自己是谁,信任记录负责描述谁在什么条件下相信它。把两者分表或至少分状态保存,可以避免“重新注册覆盖旧 metadata”后自动继承原有信任。
客户端修改回调地址时,旧信任也不应自动迁移。任何新增 URI 都应重新验证和确认,删除 URI 则应立即生效;对高风险系统,信任记录还可以设置 30 天、90 天或 180 天到期复核,而不是永久有效。
上线前至少跑完这组攻击测试
安全测试必须覆盖“合法 OAuth 流程被恶意使用”,而不只是参数缺失和签名错误。 建议把以下用例加入自动化与人工测试:
- 使用攻击者域名完成 DCR,确认注册成功不等于可以立即授权。
- 把合法域名嵌入用户名、查询参数或恶意域名后缀,确认主机比较不会误判。
- 测试大小写、默认端口、尾部斜杠、百分号编码、重复 query 和 Unicode 域名。
- 在可信回调路径加入开放重定向参数,确认授权码不会被二次转发。
- 修改已受信任客户端的回调 URI,确认新地址不会继承旧信任。
- 让 A 租户登记地址,再由 B 租户发起授权,确认租户边界有效。
- 使用合法
client_id搭配另一个客户端的redirect_uri,确认组合校验失败。 - 省略 PKCE、降级为
plain、复用授权码或更换 token exchange 中的 URI,确认全部拒绝。 - 在域名验证中指向
127.0.0.1、私网 IP、云元数据服务和多级跳转,确认 SSRF 防护有效。 - 撤销信任后重放旧授权链接,确认授权端点和令牌端点都会重新检查状态。
最后的判断:开放生态不等于开放回调
MCP 要成为通用连接层,就必须允许用户带着自己的客户端进来。 完全依赖平台预置列表会让企业自建工具和个人工作流寸步难行,但允许任意 DCR 回调又会把 MCP 授权页变成高可信钓鱼基础设施。
真正可行的折中不是寻找一个更聪明的通配符,而是建立两条清楚的边界:客户端可以自由登记,但不能自动获得信任;用户可以添加自己的信任来源,但必须在独立、可审计、可撤销的管理流程中完成。
PKCE、state、短期授权码和精确 URI 匹配仍然一个都不能少,但它们解决的是协议完整性,不是社会工程。服务端最终要验证的,不只是“这个回调地址是否与请求一致”,而是“当前用户是否曾在攻击者无法顺手操纵的场景中,明确同意信任它”。
参考来源
- Model Context Protocol 规范仓库:MCP 核心规范及授权机制的官方维护仓库,可用于核对授权服务器发现、客户端注册和资源访问要求。
- OAuth 2.1 工作组草案仓库:OAuth 2.1 安全基线的公开维护仓库,涵盖授权码流程、PKCE 与重定向 URI 等要求。
- OAuth 浏览器应用安全草案仓库:浏览器客户端的令牌处理、重定向和攻击面分析,可作为 Web MCP 客户端的补充参考。



