Caputchin
理解 Caputchin

Caputchin 如何沙箱化游戏

查看 Markdown

一个 Caputchin 游戏是跑在你访客浏览器里、在你页面上的第三方代码。无论它来自 Marketplace 还是你自己托管它,Caputchin 都把它当作 默认不被信任 的,并把它裹进几道独立的隔离层里,于是一个有 bug 的或敌意的游戏能渲染、能玩、能报告一个结果,却够不到别的任何东西:够不到你的页面、够不到你访客的数据、够不到网络。

这一页解释每一道措施、每一道为什么存在、以及这整件事如何和你加到自己站点上的一个 CSP 互动。

一行话讲清威胁模型

游戏跑由一个第三方提供的任意 JavaScript(以及可能的 WebAssembly)。因此隔离目标是:游戏能计算和绘制,能把一个结果交回给组件,但它无法读取或影响它自己那个框之外的任何东西。 下面每一层都服务于那一个目标,而且它们是纵深防御,没有单独一层被信任为完美。

第 1 层:一个不透明来源的沙箱化 iframe

每个游戏都跑在组件用一个刻意极简的 sandbox 属性构建的 <iframe> 里。被授予的 唯一 令牌是 allow-scripts,因为游戏是 JavaScript、必须跑。其他一切都被扣下,而最重要的省略是 allow-same-origin。

没有 allow-same-origin,浏览器就给这个框一个独一的 null 来源。那一个事实就是那道承重的边界:

  • 游戏读不到你页面的 DOM、cookie、localStorage 或 Caputchin 会话,它被密封进一个一次性的、对你而言陌生的来源里。
  • 因为这个框也永远无法导航你的顶层窗口、打开弹窗、提交表单或弹出原生对话框(所有那些能力都是 Caputchin 不授予的沙箱令牌),所以没有一条从游戏回到你页面的路径。

allow-scripts 而无 allow-same-origin 的组合就是全部要点:浏览器执行游戏的代码,但把这个框当作一个碰不到你任何东西的陌生来源。Caputchin 从不给一个游戏框加 allow-same-origin。(作为一个后果,游戏发给组件的每条消息都来自来源 "null",组件的通道检查正预期它,一个非 null 的来源本身就会示意一个配置错误的沙箱。)

第 2 层:框的文档是内联构建的,不是拉取的

组件不把 iframe 指向一个远程页面。它 内联地(通过 srcdoc)构造框的整个 HTML 文档,并把它交给那个 null 来源的框。那个文档很小:一个严格的 CSP meta 标签(下一层)、一小段 Caputchin 运行时引导、以及一个加载游戏包的 <script> 标签。

这对两条交付路径是同一个机制,只有包的 URL 不同:

游戏来源内联文档里的包 URL
Marketplace(平台解析)Caputchin 在挂载时解析出的那个固定的、带完整性哈希的包 URL。
自托管(你的 game-src)你提供的那个 URL。

因为这个文档是由组件、而非游戏撰写的,游戏从不控制 CSP、运行时或框的结构,只控制它自己的包一经加载后做什么。

第 3 层:给 Marketplace 游戏的子资源完整性

当 Caputchin 提供一个 Marketplace 游戏时,它把包钉到一个不可变的版本,并记录它的一个加密哈希(SHA-384)。加载这个包的 <script> 标签带着那个哈希作为一个 integrity 属性、配 crossorigin="anonymous",于是如果有一个字节和被钉住的不同,浏览器自己 就拒绝执行这个包。一个被攻陷的 CDN 无法替换成不同的代码;加载失败关闭。

一个自托管的游戏没有平台断言的哈希(对你自己的字节,你是信任根),所以那条路径就干脆省略 integrity 属性。一个 Marketplace 游戏的 结果 如何在服务器上被独立地重新推导,见 回放契约,那是一个和完整性分开的保证。

第 4 层:一个严格的内联 Content-Security-Policy

那个内联文档带着一个收紧的 Content-Security-Policy。即便这个框已经是 null 来源且沙箱化的,CSP 还进一步约束被加载的代码可以做什么,它默认拒绝一切,并只重新授予最低限度:

指令值为什么
default-src'none'拒绝下面没有被明确允许的一切。
script-srcCaputchin 自己内联运行时的一个哈希 + 游戏包的来源 + 'wasm-unsafe-eval'只跑 Caputchin 那个确切的引导(由一个 sha256- 哈希钉住,而非 'unsafe-inline')和游戏自己的包。'wasm-unsafe-eval' 让一个 WASM 引擎编译,而不 授予完整的 'unsafe-eval'。
connect-src'none'那条反外泄指令。 游戏无法 fetch、XHR、打开一个 WebSocket 或向任何地方发信标。完全没有网络出口。
img-srcdata:(外加任何皮肤资源来源,见下文)精灵图内联,或来自一个被允许的资源主机。
media-srcdata:(外加任何皮肤资源来源,见下文)音频/视频内联,或来自一个被允许的资源主机。
font-srcdata:只内联字体。
style-src'unsafe-inline'游戏设置内联样式;没有外部样式表。(样式无法像脚本或网络那样外泄数据。)

净效果:游戏跑、渲染、并通过 SDK 桥报告它的结果,而够不到别的任何东西,尤其够不到网络。这个 CSP 由 Caputchin 设置,不是你配置的东西;它列在这里,是为了让你确切理解一个游戏被收容得有多紧。

一个客户设置加宽框 CSP 的唯一地方

皮肤可以把图像或音频字段指向你自己 CDN 上的一个绝对 URL(见 皮肤)。为了让那些资源能在这个锁死的框里加载,Caputchin 把 恰好那些资源来源 加到框的 img-src / media-src 上,别无他处。script-src 和 connect-src 从不被加宽,于是即便一个被允许的资源来源也无法被用来加载代码或打开一条网络通道。这是一个被配置的值影响框策略的那唯一、狭窄的方式,而且它仍然只限资源。

第 5 层:一条单向的结果通道

游戏没有一个能调进你页面的面。出去的唯一方式是 SDK 桥:游戏调用单一一个方法,把它不透明的结果(它的轨迹)postMessage 给组件,而组件住在你的来源上。和 Caputchin 的 API 对话的是组件;游戏从不(它不能,connect-src 是 'none')。所以那条信任路径是 游戏 → 组件 → 你的后端,而每一跳都只把一个结果往前传,绝不授予游戏向后的触及范围。

你设在自己页面上的那个 CSP

上面那些层保护 你和你的访客免受游戏之害。你自己页面上的一个 Content-Security-Policy 是一个不同的、互补的控制:它保护 你的页面免受一切之害。

这一节的一切都由一个事实驱动:游戏框继承你页面的策略。 这个框是一个内联的 srcdoc 文档,而浏览器会把嵌入页的 CSP 叠加到这类文档自己的策略之上。所以上面那份被锁死的框策略是 Caputchin 加上去的一层底线,不是对你那份的替代。任何在框里跑的东西都得同时满足 两份,也就是说你页面的策略必须允许游戏的包和框自己的引导脚本,哪怕这两样都不是你的页面直接加载的。

至少,组件需要:

指令允许为什么
script-src你加载组件脚本的那个来源,外加 'unsafe-eval'那个定义 <caputchin-widget> / <caputchin-game> 的 <script>、你选的那个 CDN(jsDelivr、你自己的主机或 caputchin.com)。'unsafe-eval' 让 探测 挑战能跑;探测开着时它是必需的,你可以通过在密钥的 安全 页上把探测关掉来去掉它。
script-src(仅限游戏)游戏包的来源,外加 'unsafe-inline' 和 'wasm-unsafe-eval'因为框继承这份策略。包的来源对 Marketplace 游戏是 https://games.caputchin.com,对 自托管游戏 则是你自己的主机或 CDN。'unsafe-inline' 管的是框自己的引导脚本:框用 sha256- 哈希把它钉住,你的页面却钉不住,因为那个哈希随组件的每次发布而变。'wasm-unsafe-eval' 是建在 WebAssembly 引擎上的游戏需要的。如果你只用不带游戏的 <caputchin-widget>,这些都不适用。
connect-srchttps://verify.caputchin.com,外加你加载组件脚本的那个来源这是两次不同的调用。组件调用 Caputchin 验证服务来搭建并确认一次验证,所以允许这个确切的来源(如果你把组件指向一个不同的 API 主机,就改为允许那个)。proof-of-work 求解器还会从组件脚本旁边取它的 WebAssembly 模块,所以那个 CDN 来源既要在 script-src 里,也要在这里。如果你把组件自托管在自己的来源上,'self' 就把第二项盖住了。
worker-srcblob:proof-of-work 求解器完全跑在从一个 blob: URL 创建的 Web Worker 里,没有主线程回退,所以这是必需的。如果你省略 worker-src,浏览器会回退到 child-src 再到 default-src,而单凭 default-src 'self' 不允许 blob:。

把 'wasm-unsafe-eval' 加到 script-src,好让 proof-of-work 求解器作为快速的 WebAssembly 跑。这个关键词和求解器取模块的那个来源,都不是验证成功的必要条件:缺了其中任何一个,求解器都会退回到一个较慢的纯 JavaScript 实现(仍在 Worker 里),验证照样能用。但它们只有凑齐才有回报。求解器先 fetch 那个 WebAssembly 模块,再编译它,所以这条快路既需要 connect-src 里有组件脚本的来源,也 需要 script-src 里有 'wasm-unsafe-eval';只给其中一个,你就会留在慢的求解器上,而控制台里不会有任何东西告诉你为什么。

你 不 需要为游戏加一条 frame-src。游戏框是一个内联的 srcdoc 文档,而不是从某个 URL 取来的文档,所以没有 URL 可供 frame-src 匹配,就算在 frame-src 'none' 之下这个框也照样加载。

你 确实 需要、而且常让人意外的,是把游戏包的来源写进你页面的 script-src,哪怕你的页面从不加载那个包:加载它的是框,而框既在自己的策略下跑,也在你的策略下跑。框的引导脚本和 WebAssembly 游戏引擎同理。给出这些许可并不会拓宽游戏能够到的范围:框自己的策略依然钉着 connect-src 'none',框依然是不透明来源,你页面给出的许可也松不开框给自己设的那道底线。

script-src 里的 'unsafe-inline' 是这份清单里让人不舒服的那一项,把话说明白才诚实:今天要跑游戏就非它不可,它削弱你自己页面对内联脚本的防护,而替代方案(钉住框引导脚本的 sha256- 哈希)在那个哈希随组件每次发布而变的前提下并不现实。如果某个页面无法接受这笔交换,就在那里用 <caputchin-widget>,它不需要仅限游戏那一行里的任何东西。

如果一个 Caputchin 元素在你的 CSP 下默不作声地加载失败,你的浏览器控制台会点名被挡的那条指令;把它点名的来源或关键词加到那条指令上。从严格起步,并恰好打开控制台所要求的;还要像读针对你自己页面的违规一样,仔细读针对 about:srcdoc 报出来的违规:那些是游戏框撞上了你被继承过去的策略,也正是大家最容易漏掉的。你确实需要给 worker 的 blob:(worker-src),而当探测开着时,需要 script-src 里的 'unsafe-eval';如果你在密钥的 安全 页上把探测关掉,那个 'unsafe-eval' 要求就消失。按上面那张表,'unsafe-inline' 只有跑游戏才需要。proof-of-work 的快路是可选的,但要成对:script-src 里的 'wasm-unsafe-eval' 和 connect-src 里组件脚本的来源。这一对是控制台帮不了你的唯一一种情况,因为退回 JavaScript 求解器并不算一个错误。

一份能用的策略

从 jsDelivr 加载组件、用 Marketplace 游戏、探测开着:

default-src 'none';
script-src  https://cdn.jsdelivr.net https://games.caputchin.com 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval';
connect-src https://verify.caputchin.com https://cdn.jsdelivr.net;
worker-src  blob:;
img-src     'self' data: https:;
style-src   'unsafe-inline'

如果你不用游戏,就去掉 https://games.caputchin.com、'unsafe-inline' 和 'wasm-unsafe-eval'。如果你把探测关掉,就去掉 'unsafe-eval'。如果你自托管组件,就在 script-src 和 connect-src 里都把 https://cdn.jsdelivr.net 换成你自己的来源。

为什么这么多层

每一层单独都会是一道有意义的边界;合在一起它们意味着其中一层的失效不是一次失守。单凭 null 来源就已经把游戏和你的数据隔开;单凭 CSP 就已经掐死网络出口;单凭完整性就已经钉住那确切的字节。Caputchin 把它们全跑起来,因为不被信任的第三方代码恰恰是纵深防御物有所值的地方。

另见

本页内容