跳到主要内容

安全模型与沙箱

本章讲为什么 MCP Apps 敢把服务器给的 HTML 直接渲染进对话。前置:01 章的传输层、03 章的 Host 守门。

1. 威胁模型:UI 是不可信的

那份 HTML 来自第三方 MCP 服务器,必须当作不可信代码。它绝不能:读取对话内容、冒充用户发消息、随意外联偷数据、拿到宿主页面的 DOM/cookie。MCP Apps 用三层防线扣住它:

  1. 隔离 —— 关进沙箱 iframe,与宿主异源。
  2. 限流出 —— CSP 默认禁一切外部连接,只放服务器显式声明的域。
  3. 过闸 —— View 没有自己的服务器连接;所有 MCP 请求都经 Host 转发,Host 可审可拒。

2. 双层 iframe:沙箱代理(web 宿主)

要解决的小问题: 仅靠一层 sandbox iframe 不够——若用 allow-same-origin 又和宿主同源,UI 就能碰宿主的 storage/DOM。规范要求 web 宿主必须用**异源的中间“沙箱代理”**来包住 View(specification/2026-01-26/apps.mdx:472)。

┌─────────────────── Host 页面(宿主源)───────────────────┐
│ AppBridge │
│ │ postMessage │
│ ┌──▼──────────── Sandbox Proxy(异源 iframe)────────┐ │
│ │ sandbox="allow-scripts allow-same-origin" │ │
│ │ │ 注入 CSP + 把内层包进去 │ │
│ │ ┌──▼──────── View(内层 iframe,跑不可信 HTML)──┐ │ │
│ │ │ CSP 锁死外联;只能 postMessage 给上层 │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘

怎么读:外层沙箱代理与宿主不同源(关键!),所以即便它有 allow-same-origin,那个“same origin”也是沙箱自己的源、不是宿主的;内层 View 再被它用 CSP 锁住外联。

载入握手(规范 §sandbox proxy,apps.mdx:476-477,SDK 内部消息见 spec.types.ts:237-259):

  1. 沙箱代理就绪 → 发 ui/notifications/sandbox-proxy-ready 给 Host。
  2. Host → 发 ui/notifications/sandbox-resource-ready,带上 HTML、CSP、权限。
  3. 沙箱代理用这些 CSP 设置把 HTML 渲进内层 iframe。

SDK 里 Host 侧的对应方法是 onsandboxready(事件,app-bridge.ts:560)和 sendSandboxResourceReady(app-bridge.ts:1708),都标 @internal——这是 web 宿主框架作者用的,普通 App/Server 开发者碰不到。

透明转发规则(很关键): 沙箱代理对所有不以 ui/notifications/sandbox- 开头的消息做透明转发(apps.mdx:485),包括 ui/initializetools/call 等。也就是说:沙箱代理只截留自己那两条 sandbox-* 消息,其余原样传递,所以 View 和 Host 感觉不到中间这一层。

桌面/原生宿主不需要这套双层结构(它们有原生的进程/视图隔离),可以直接渲染 View(apps.mdx:1301-1308 的时序图里 Desktop 分支只 “Render View in an iframe”)。

3. CSP:默认禁一切,按声明开白名单

服务器在资源 _meta.ui.csp 里声明 UI 要连哪些域(见 04 章 §5)。沙箱按这些声明设 CSP(apps.mdx:478-483):

  • connectDomainsconnect-src(fetch/XHR/WebSocket)
  • resourceDomainsimg-src/script-src/style-src/font-src/media-src
  • frameDomainsframe-src(没声明就 frame-src 'none')
  • baseUriDomainsbase-uri(没声明就 base-uri 'self')
  • 恒定:object-src 'none'
  • 没给任何 CSP 元数据 → 套最严默认

铁律(写在类型注释里,spec.types.ts:600-604): UI HTML 在沙箱里没有同源服务器,所以连你自己打包的 JS/CSS 的来源也必须声明——开发期是 localhost,生产是你的 CDN。漏声明 = 自己的脚本都加载不了。这是新手最常踩的坑。

4. 权限:摄像头/麦克风等按需申请

_meta.ui.permissions(spec.types.ts:664-689)声明 UI 需要的浏览器能力,沙箱可以(MAY)把它们映射到内层 iframe 的 allow 属性。Host 侧 buildAllowAttribute(app-bridge.ts:187-199)把 { microphone: {}, clipboardWrite: {} } 这种对象转成 "microphone; clipboard-write" 字符串。

注意是 MAY 不是 MUST:App 不该假设权限一定被授予,要用 JS 特性检测兜底(spec.types.ts:660-663)。

5. 第三道闸:View 没有自己的服务器连接

即便 UI 突破前两层,它也碰不到 MCP 服务器——它没有那条连接。它能做的只是 app.callServerTool() 让 Host 代调,而 Host 可以(MAY)拦截、要求用户审批、或直接拒(apps.mdx:488、03 章 §3 的各 on* 守门处理器)。能力协商进一步把范围收窄:没 sampling 能力,createSamplingMessage 在 View 侧就被 assertCapabilityForMethod 拦下(app.ts:1142-1152)。

关键细节 / 坑

  • postMessage 用 "*" origin 发(message-transport.ts:132),所以安全靠发送端锁 origin,而靠接收端 event.source 校验(message-transport.ts:78)+ 沙箱隔离。
  • 沙箱代理不应自己造请求(apps.mdx:486):它若新建请求就得伪造 id,破坏 JSON-RPC 配对;它只转发。
  • Host 转发标准 MCP 消息时可加策略(apps.mdx:488):对不以 ui/ 开头的 View 消息,Host 可选择性阻断或加审批。

下一步:06-cleverness-boundaries-map.md 收尾——精华、边界、对比、代码地图。