一个看似安全的本地服务
假设你在本机跑了一个 MCP Server,用 Streamable HTTP 传输,监听 127.0.0.1:3088,端点路径是 /mcp。它注册了一个 run-code 工具,能在你的机器上执行一段代码并返回输出。你只在本地客户端里配置了它,端口没有映射到公网,路由器也没有做转发。按直觉,外部的人碰不到这个服务。
问题出在浏览器。浏览器页面里的脚本可以向 http://127.0.0.1:3088/mcp 发 POST 请求,请求会真的到达你本机的服务进程。同源策略限制的是脚本读取跨源响应,并不阻止请求被发出。对于 MCP 这种“发一个 JSON-RPC 请求就能触发副作用”的接口,请求能不能被读到已经不重要了,工具已经执行了。
MCP 规范在 Streamable HTTP 传输一节给出了明确的安全警告:服务端必须校验所有入站连接的 Origin 头,以防止 DNS rebinding 攻击;本地运行时服务端应只绑定 127.0.0.1,而不是 0.0.0.0;服务端应为所有连接实现恰当的鉴权。规范同时指出,缺少这些保护时,攻击者可以利用 DNS rebinding 从远程网站与本地 MCP 服务交互。下面围绕这个场景,说明 Origin 校验到底拦住了什么,以及没拦住时会怎样。
Origin 头是什么,它约束了谁
Origin 是 RFC 6454 定义的 HTTP 请求头字段,用来表示发起该请求的源。一个源由协议、主机和端口三部分组成,三者任一不同就是不同的源。https://example.com 与 http://example.com 不同源,http://localhost:3000 与 http://localhost:3088 也不同源。RFC 6454 的出发点很直接:用户代理把来自不同源的内容隔离开,防止恶意站点的脚本干扰正常站点。
Origin 头由浏览器在发起跨源请求时自动附加,页面脚本无法自行设置这个头的值。这一点决定了它的安全价值:服务端看到的 Origin,是浏览器根据页面真实来源填写的,不是攻击者随手编的。服务端只要把 Origin 与一份允许列表比对,就能判断请求来自哪个页面。
这里要区分两件事。同源策略管的是“脚本能不能读响应”,Origin 校验管的是“服务端要不要处理这个请求”。前者是浏览器侧的执行限制,后者是服务端侧的准入判断。MCP 服务端需要自己做后者,因为浏览器不会替它拒绝一个跨源的 POST。
攻击路径:从恶意页面到工具执行
先看没有 DNS rebinding 的简单情形。恶意页面直接向 http://127.0.0.1:3088/mcp 发请求,浏览器会带上 Origin: https://evil.example。如果服务端不检查这个头,请求体里的 initialize、tools/call 会照常被处理。脚本读不到响应没关系,run-code 的副作用已经发生。这种请求甚至不需要预检通过就能发出简单请求,而带 application/json 的 POST 会触发预检,服务端若不处理 OPTIONS,浏览器会拦下实际请求。所以攻击者更愿意选择能绕过预检的路径,或者利用服务端对预检的宽松处理。
DNS rebinding 解决的是另一个障碍:主机名。攻击者控制一个域名,先把它解析到自己的服务器,让页面加载完成;随后把这个域名的 DNS 记录改成 127.0.0.1,并让 TTL 很短。浏览器再次解析同一域名时,请求就打到了你本机的服务。从浏览器角度看,页面源始终是攻击者的域名,请求也是同源请求,同源策略不再构成障碍。此时请求里的 Host 头是攻击者的域名,Origin 也是攻击者的域名。
这条路径说明,仅靠“只监听回环地址”不足以防御。回环绑定挡住了局域网和公网直连,但挡不住本机浏览器替远程页面发出的请求。服务端必须在应用层判断请求来自哪个页面。
flowchart TD
A[恶意页面加载] --> B[域名解析到攻击者服务器]
B --> C[脚本准备调用本地 MCP]
C --> D[DNS 记录改为 127.0.0.1]
D --> E[浏览器向本机 3088 端口发 POST]
E --> F{服务端校验 Origin}
F -->|缺失或过宽| G[处理 tools/call]
G --> H[run-code 在本机执行]
F -->|不在允许列表| I[返回 403 并记录]
I --> J[工具不执行]
图中关键转折在 F 节点。前面的步骤浏览器都按规则行事,唯一能打断链条的是服务端自己的判断。
校验点应该放在哪里
Streamable HTTP 的端点同时接受 POST 和 GET。POST 用来发送 JSON-RPC 消息,GET 用来打开 SSE 流接收服务端消息。两条路径都要校验,因为 GET 也能建立会话通道,后续消息可能沿这条通道下发。规范要求的是“所有入站连接”,不是只校验 POST。
校验位置应在路由分发之前、JSON-RPC 解析之前。原因很实际:如果先解析请求体再校验,攻击者已经能让服务端做一轮反序列化和方法查找,错误处理路径也可能泄露端点存在与否。更稳妥的顺序是读取 Origin 头、比对允许列表、不通过就直接返回 403,不进入 MCP 会话层。
允许列表本身要写死,不能做前缀匹配或包含匹配。工程上常见的一种过宽写法是判断 Origin 是否以 http://localhost 开头,这会放行 http://localhost.evil.example。另一种是只判断 Origin 是否存在,等于没校验。正确做法是把完整的源字符串与配置中的条目逐一相等比较,包括协议和端口。
对于不带 Origin 头的请求,要单独决策。非浏览器客户端(命令行工具、SDK)通常不发 Origin。如果服务端一律拒绝无 Origin 请求,本地脚本调用会被误伤;如果一律放行,攻击面又回来了。一种可操作的折中是:无 Origin 时要求携带有效令牌,有 Origin 时同时满足允许列表和令牌。这需要服务端明确记录两类请求的计数,便于发现异常。
校验过宽与缺失时的具体后果
把校验写成“Origin 包含 localhost”会怎样?攻击者注册 localhost.evil.example,页面源就是 http://localhost.evil.example,字符串包含 localhost,校验通过。DNS rebinding 甚至不需要,直接向 127.0.0.1 发请求即可,因为服务端已经认可了这个源。
完全不校验时,后果取决于工具的能力。run-code 这类工具等于把本机 shell 暴露给任何能触达端口的页面。文件读写类工具可以读取本地配置、密钥文件;数据库工具可以查询本地数据。即使工具只读,信息也会通过副作用外泄,例如把结果写到一个攻击者能访问的位置,或通过请求参数把数据带出去。
还有一种容易忽略的情况:服务端校验了 Origin,但允许列表里放了通配或开发用的宽松条目,上线时忘了收紧。这类配置漂移在本地开发环境很常见,因为开发者需要从不同端口的页面调试。可观测手段应当能暴露这类配置:统计被拒绝的 Origin 值分布,如果出现大量陌生域名,说明有页面在尝试;如果允许列表命中数异常高,需要核对配置。
与回环绑定、令牌鉴权的对比
三种手段针对的威胁不同,不能互相替代。回环绑定限制的是网络可达范围,令牌鉴权限制的是调用者身份,Origin 校验限制的是发起请求的页面来源。
| 方案 | 拦住什么 | 拦不住什么 | 部署成本 | 对本地客户端的影响 |
|---|---|---|---|---|
| 仅绑定 127.0.0.1 | 局域网与公网直连 | 本机浏览器替远程页面发的请求、DNS rebinding | 低,改监听地址即可 | 无 |
| 仅校验 Origin | 非允许源的浏览器请求 | 不带 Origin 的非浏览器客户端伪造、本机其他进程直连 | 低,需维护允许列表 | 无 Origin 的客户端需另行处理 |
| 仅令牌鉴权 | 无有效令牌的调用 | 令牌泄露后的滥用、浏览器页面拿到令牌后的调用 | 中,需分发与轮换 | 需配置令牌 |
| 三者组合 | 上述大部分路径 | 本机已被攻陷后的进程级攻击 | 中 | 需同时满足来源与令牌 |
从这张表能看出,绑定回环是必要但不充分的条件。规范把它写成 SHOULD,把 Origin 校验写成 MUST,这个强弱差异反映了威胁模型的侧重:浏览器是本地服务最现实的入口。令牌鉴权在纯本地场景下价值有限,因为令牌要存在客户端配置里,页面若能诱导用户粘贴或从本地配置读取,令牌同样会泄露。它的主要价值在于多用户或远程部署时区分调用者。
工程上通常的组合是:绑定 127.0.0.1,强制 Origin 校验,对无 Origin 请求要求令牌,并把三者都做成可配置项而不是硬编码。
可观测指标与排障
上线后需要能回答两个问题:有没有人在尝试跨站调用,以及正常客户端有没有被误伤。
建议记录以下信号。第一,按 Origin 值分组的请求计数,被拒绝的单独一列。第二,无 Origin 请求的比例,这个比例突然升高可能意味着有非浏览器客户端接入,也可能是攻击者在试探。第三,403 响应数与总请求数的比值,短时间内的尖峰通常对应扫描或页面尝试。第四,Host 头与 Origin 不一致的请求数,DNS rebinding 场景下 Host 是攻击者域名,而 Origin 也是同一域名,两者一致但与允许列表不符,这类请求应重点记录。
排障时常见的误判是把正常客户端拦掉。如果客户端是浏览器内的应用,它的 Origin 是页面地址,需要加入允许列表;如果客户端是桌面应用,可能不发 Origin,需要走令牌路径。日志里应同时记录 Origin、Host、User-Agent 和请求方法,才能在出现 403 时快速判断是哪一类调用。
前提不成立时方案会退化
Origin 校验的有效性依赖一个前提:浏览器会如实填写 Origin,且页面脚本无法篡改。这个前提在标准浏览器中成立。如果请求不是浏览器发出的,Origin 头可以被任意伪造,此时校验形同虚设,必须依靠令牌或网络层隔离。
另一个前提是允许列表本身正确。配置错误、开发残留、通配条目都会让校验失效,而这类问题不会在功能测试中暴露,因为正常客户端本来就在允许列表里。需要把允许列表纳入配置审查,并在启动时打印生效的列表内容。
还有一种边界:如果服务端同时提供其他 HTTP 接口,比如健康检查或静态资源,这些接口可能没有走同一套校验中间件。攻击者可以先探测这些接口确认服务存在,再针对 MCP 端点发起请求。校验应覆盖整个监听端口的入站请求,而不是只挂在 /mcp 路径上。
最后,Origin 校验不解决工具本身的权限问题。即使请求来自允许的页面,工具执行时仍应以最小权限运行,避免一次误调用造成不可逆的后果。校验是准入层的一道门,不是全部防线。