技术分析

MCP SDK 的 Host 校验漏在了授权服务器上

复现环境:Ubuntu 24.04.4 LTS,Python 3.12.3,venv 隔离。授权服务器和 MCP 资源服务器都跑在这一台机器上,监听 127.0.0.1,端口 9000 和 8001;代码取自官方示例examples/servers/simple-auth(tag v2.3.0)。对照组另外起了两个实例:一个跑 2.0.0 的授权服务器(端口 9100),一个给授权服务器套上 SDK 自带的传输安全中间件(端口 9001)。文中出现的 Host 值、客户端凭据都是我自己写的测试值,命令在 SSH 里执行,输出没有修改过,截图可复现。

写在前面

这篇讲一个我查了几天才敢下结论的缺口:MCP 官方 Python SDK 会给传输层的请求做 Host/Origin 校验,不合规直接回 421,但它自带的那套 OAuth 授权服务器不做这件事。授权服务器是发访问令牌的地方,两种请求落在同一个进程里。

版本是现查的,不是凭印象写的:mcp在 PyPI 上的最新发行版是 2.3.0(2026-10-02 上传),1.x 线最新是 1.30.0(2026-09-07)。下面所有结论都是在这条线上验证的。

起环境就三条命令:

python3 -m venv ~/mcp-venv
~/mcp-venv/bin/pip install mcp uvicorn pydantic-settings click requests
cd ~/asdemo
setsid nohup ~/mcp-venv/bin/python -m mcp_simple_auth.auth_server --port 9000 &
setsid nohup ~/mcp-venv/bin/python -m mcp_simple_auth.server     --port 8001 &

结构大概是这样:

攻击者页面(浏览器里)
                |
                | DNS rebinding:攻击者域名解析到 127.0.0.1
                v
   +---------------------------- 本机 127.0.0.1 ----------------------------+
   |                                                                        |
   |   :9000  自带 OAuth 授权服务器                                          |
   |          /.well-known/oauth-authorization-server   /authorize          |
   |          /register   /token   /revoke   /introspect                     |
   |          ^ Host/Origin 不校验                                           |
   |                                                                        |
   |   :8001  MCP 资源服务器(streamable-http 的 /mcp)                       |
   |          ^ Host 不对 -> 421 Invalid Host header                         |
   +------------------------------------------------------------------------+

导览一句:第一节说清这两个面为什么会被放在一起看,第二节到第四节是源码和实测的对照,第五节把链路走完,第六节往版本历史里找原因,第七节查台账,第八节划边界,剩下两节是工具和修复。

一、先看清这两个面

传输层那套防护是干嘛的

MCP 的 HTTP 传输跑在本机端口上的场景很常见:用户在自己电脑上起一个 server,给本地的 AI 客户端连。这种部署最大的风险之一是 DNS rebinding —— 攻击者让一个域名解析到 127.0.0.1,受害者浏览器里的页面就和本机服务变成同源,可以随便发请求、也能读响应。

SDK 为此写了专门的中间件:请求进来先看 Host 头在不在白名单里,Origin 头对不对,不在就返回 421 Misdirected Request。白名单支持127.0.0.1:*这种写法,端口部分做通配。sse_app()和streamable_http_app()在绑定地址是回环地址时会自动把防护打开。跑在本机的 Docker 或裸进程按默认写法起服务,这道检查是开着的。

自带授权服务器是另一个东西

除了传输,SDK 还提供一整套 OAuth 2.0 授权服务器:create_auth_routes()返回 metadata、authorize、token、register、revoke 五组路由,配一个 provider 就能跑。官方示例servers/simple-auth就是照着这个搭的,另外加了登录页、登录回调和自省端点。

做 MCP 的人对这个不陌生:客户端要拿令牌才能连受保护的 server,很多部署就是拿这套路由直接当授权服务器用,或者把它当成对接企业 IdP 的中间层。

它们在同一个进程里

我一开始没把这两块放在一起想过。直到翻transport_security.py的时候发现一件事:传输层的防护和授权服务器需要的那个请求体限制,是写在同一个文件里的两个类。一个文件承担两种校验,说明维护者对 HTTP 面上该做什么是清楚的。那就该问一句:授权服务器那边的请求,有没有走同一套检查。

二、两个类写在同一个文件里

一个管 Host,一个管大小

src/mcp/server/transport_security.py里定义了三个类,后面两个是重点:

class TransportSecuritySettings(BaseModel):      # 白名单配置
class TransportSecurityMiddleware:               # 校验 Host/Origin,不过就返回 421
class RequestBodyLimitMiddleware:                # 限制请求体大小,超了返回 413

前者就是第一节说的那套防护;后者是 2026 年 8、9 月才补进来的,管请求体大小。

授权服务器只 import 了其中一个

去看src/mcp/server/auth/routes.py的 import:

from mcp.server.transport_security import DEFAULT_MAX_REQUEST_BODY_SIZE, RequestBodyLimitMiddleware

只拿了请求体限制那个类。Host/Origin 校验的类,一个字都没提到。

五组路由的挂法是这样:

Route(
    AUTHORIZATION_PATH,
    endpoint=_body_limited(request_response(AuthorizationHandler(provider).handle)),
    methods=["GET", "POST"],
),
Route(
    TOKEN_PATH,
    endpoint=_cors(_body_limited(request_response(token_handler.handle)), ["POST", "OPTIONS"]),
    methods=["POST", "OPTIONS"],
),

挂的是_cors和_body_limited两个装饰器,一个管跨域响应头,一个管请求体大小。没有第三个。

把两个文件里的用法拉出来对数:

$ grep -n "validate_request" .../mcp/server/streamable_http.py .../mcp/server/sse.py
streamable_http.py:506:    error_response = await self._security.validate_request(request, is_post=is_post)
sse.py:145:            error_response = await self._security.validate_request(request, is_post=False)
sse.py:235:            error_response = await self._security.validate_request(request, is_post=True)

$ grep -rn "TransportSecurityMiddleware" .../mcp/server/auth/
(没有输出)

传输侧命中七行,其中三行是对中间件的调用,另外四行是它内部的转发辅助函数;授权服务器侧零行。这两条命令的差别,就是这篇文章的全部内容。

整个 auth 目录搜不到 Host 校验

不放心只看这一个文件,把整个server/auth/目录扫了一遍:handlers、middleware、routes、settings 全算上,搜host、origin、referer,命中的只有两处跟请求无关的东西 —— issuer URL 的格式校验,和allow_origins="*"这个跨域配置。真正对请求头做判断的代码,一行都没有。

三、传输侧确实在守

往 /mcp 打一个伪造 Host

光看代码不够,得打一发。先准备一个令牌(第五节的链路会完整走一遍,这里直接用结果),然后打一发正常的,确认这条路径本身是通的:

curl -s -o /dev/null -w "%{http_code}\n" -X POST \
  -H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" -d '{}' http://localhost:8001/mcp
# 400 —— 请求体是空的,业务层报参数不对,说明请求已经进到传输里了

然后 Host 写成一个不存在的域名:

curl -s -o /dev/null -w "%{http_code}\n" -X POST \
  -H "Host: evil.example" -H "Authorization: Bearer $TOK" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{}' http://localhost:8001/mcp

返回 421。资源服务器的日志里能看到是谁拦的:

WARNING:mcp.server.transport_security:Invalid Host header: evil.example
INFO:     127.0.0.1:39402 - "POST /mcp HTTP/1.1" 421 Misdirected Request

legacy SSE 也一样

换一个传输试试,看是不是某一个实现漏了。示例支持--transport sse,起在 8002:

curl -s -o /dev/null -m 3 -w "%{http_code}\n" -H "Authorization: Bearer $TOK" http://127.0.0.1:8002/sse

# 渗透测试 # 漏洞分析 # 网络安全技术

已在FreeBuf发表 0 篇文章

本文为 独立观点,未经授权禁止转载。
如需授权、对文章有疑问或需删除稿件,请联系 FreeBuf 客服小蜜蜂(微信:freebee1024)

被以下专辑收录,发现更多精彩内容

+ 收入我的专辑

+ 加入我的收藏

相关推荐

  • 0 文章数
  • 0 关注者

文章目录

写在前面

一、先看清这两个面

  • 传输层那套防护是干嘛的
  • 自带授权服务器是另一个东西
  • 它们在同一个进程里

二、两个类写在同一个文件里

  • 一个管 Host,一个管大小
  • 授权服务器只 import 了其中一个
  • 整个 auth 目录搜不到 Host 校验

三、传输侧确实在守

  • 往 /mcp 打一个伪造 Host
  • legacy SSE 也一样
  • 401 为什么排在 421 前面

四、授权服务器这一面不设防

  • 我怎么探的
  • 七个端点的实际返回
  • 预检放行但缺私有网络头

五、把链路走完

  • 注册一个回调指向攻击者的客户端
  • 授权与登录回调
  • 换令牌
  • 拿令牌打 MCP
  • 六步请求的清单
  • 这一段我用的是示例的演示账号

六、版本对比:补了旁边那条,没补这条

  • 2.0.0 与 2.3.0 各起一个实例
  • 5MB 请求体那个对照
  • 两条线的版本情况

七、提交史与台账

  • 传输侧这一年的账
  • 授权服务器侧的十九次提交
  • issue 与公告里搜不到

八、边界:谁受影响,谁不受

  • 直连 SDK 组装的部署
  • FastMCP 为什么没事
  • 一个前提:动态客户端注册得是开着的
  • 结论范围就一句话

九、工具

  • 它校验什么
  • 退出码
  • 跑出来的样子
  • 局限

十、修复建议(都跑过)

  • 直接挂 SDK 中间件会打断表单流程
  • 拆开校验或至少查 Host
  • 部署侧现在就能做的

原文链接: MCP SDK 的 Host 校验漏在了授权服务器上