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
- 部署侧现在就能做的