沙箱的三种真做法
第 14 章证明了同一个运行时里关不住人,第 15 章证明了同一个线程里掐不死人。这一章给出正面答案:三种真正有效的隔离手段,各自能挡住什么、要付什么代价。并且我们会把同一段探针代码放进三种环境真的跑一遍 —— 你会看到一个很有意思的结果:逃逸在 Worker 里依然成功,但它逃进了一间空屋子。
先看三种环境的实测
探针代码会依次去摸三样东西:宿主给的 ctx、全局的 document、以及通过 Function 构造器逃出去的 globalThis。
点那个按钮,第三栏会真的开一个 Worker 跑同一段代码:
第三栏的结果值得盯着看:
逃逸拿到 globalThis: object,其中 document 是 undefined,localStorage 是 undefined
逃逸成功了。Function 构造器在 Worker 里照样能用,globalThis 照样拿得到 —— 因为那是语言规范的一部分,不可能被移除。
但它拿到的那个 globalThis 里什么都没有。没有 document、没有 localStorage、没有你的 DOM、没有你的 cookie。
黑名单(Proxy 沙箱、vm):环境里什么都有,你去一样样堵。漏一个 → 全线失守。
白名单(Worker、iframe、进程、WASM):环境里什么都没有,你去一样样给。漏一个 → 插件少个功能。
安全性的差别不在「防得多严」,在「失误的代价是什么」。任何需要你保持完美的方案都会失败,因为你不可能一直完美。
做法一:Proxy 受限对象(不是沙箱,但很有用)
先把它的定位钉死:Proxy 解决的是「API 表面」,不是「运行时边界」。第 14 章已经证明它挡不住逃逸。
但它在自己的领域里非常好用:
function guard(target, allowed, onDeny) {
return new Proxy(target, {
get(obj, prop) {
if (typeof prop === 'symbol') return obj[prop];
if (!allowed.has(prop)) {
onDeny(prop); // ← 记录 + 报错,而不是静默返回 undefined
throw new TypeError(`不允许访问 "${String(prop)}"`);
}
return obj[prop];
},
set(obj, prop, value) {
throw new TypeError('这个对象是只读的'); // ← 防止插件改宿主状态
},
has(obj, prop) { return allowed.has(prop); }, // 让 'x' in obj 也一致
ownKeys(obj) { return [...allowed]; }, // 让 Object.keys 也一致
deleteProperty() { throw new TypeError('不允许删除'); },
});
}
三个设计细节值得学:
① 越权访问要抛错,不要静默返回 undefined。静默的话,插件作者会看到一个莫名其妙的 Cannot read property 'x' of undefined,查半天;抛错的话,他立刻知道是权限问题。
② 把 has 和 ownKeys 也拦上。否则插件用 Object.keys(ctx) 能看到那些它访问不了的属性名 —— 这本身就是信息泄漏(它能推断出你的内部结构)。
③ 拦截 set 和 deleteProperty。不然插件可以往 ctx 上挂东西,或者删掉某个方法,影响后续调用(甚至影响别的插件,如果 ctx 被共享了的话 —— 所以 ctx 绝不能共享)。
Proxy 的真正价值场景:
- 把一个大对象「削」成插件该看到的那部分(不用为每个能力写一遍包装函数);
- 给所有访问加审计日志 —— 「这个插件到底用了哪些 API」,这是设计 API 演进时的宝贵数据;
- 实现权限闸门(第 17 章的主题)。
做法二:Worker(浏览器里最轻的真隔离)
Worker 的隔离是结构性的:它是一个独立的 JS 全局环境,从创建那一刻起就没有 DOM。
// 宿主侧
const blob = new Blob([wrapPlugin(pluginSource)], { type: 'text/javascript' });
const url = URL.createObjectURL(blob);
const worker = new Worker(url);
// 掐死它 —— 这一行在同进程里是不可能实现的
worker.terminate();
URL.revokeObjectURL(url); // ← 别忘了,不然 blob URL 会泄漏
| Worker 挡住了 | Worker 挡不住 |
|---|---|
访问 DOM、localStorage、cookie | fetch(Worker 里有!要靠 CSP 限制) |
| 读写宿主的任何变量 | 耗尽 CPU(但你能 terminate) |
| 卡死 UI 线程 | 耗尽内存(可能拖垮整个页面) |
| 污染宿主的原型链 | importScripts 加载外部代码(同样靠 CSP) |
注意右栏第一行:Worker 里有 fetch。这意味着一个恶意插件依然能把它拿到的数据发出去 —— 它只是拿不到什么值钱的数据了(没有 DOM、没有 cookie、没有 localStorage)。
如果要连这个也挡住,需要给页面配 CSP(connect-src 限制能连哪些域),这是一条独立的防线。Chrome 扩展的 MV3 就是这么做的:它对扩展页面强制了严格的 CSP,禁止远程代码执行。
Worker 方案的实际工程量
诚实地说,用 Worker 做插件宿主,主要成本不在隔离本身,而在你要为它建一整套 RPC:
// 宿主侧要写的东西
const pending = new Map();
let seq = 0;
worker.onmessage = (e) => {
const m = e.data;
if (m.t === 'call') {
// 插件在调宿主的 API
Promise.resolve(dispatch(m.method, m.args))
.then(value => worker.postMessage({ t: 'ret', id: m.id, value }))
.catch(err => worker.postMessage({ t: 'err', id: m.id, msg: err.message }));
} else if (m.t === 'ret') {
pending.get(m.id)?.resolve(m.value);
pending.delete(m.id);
}
};
再加上:超时处理、Worker 崩溃后的重建、把宿主 API 的形状映射成方法名、错误的跨边界传递(Error 对象是不能直接 postMessage 的,要自己拆成普通对象)……
第 18 章会把这座桥完整搭一遍,你会看到它的全貌,也会看到它逼出来的那个设计决定。
做法三:iframe(当插件需要画界面时)
Worker 的一个硬限制是完全没有 DOM。如果你的插件需要画自己的界面(一个自定义面板、一个可视化图表),Worker 就不够了。
这时候用 sandbox 属性的 iframe:
const frame = document.createElement('iframe');
// sandbox 属性:默认全部禁止,然后一样样加回来
frame.sandbox = 'allow-scripts'; // 只允许跑脚本
// 故意不加 allow-same-origin → 它的 origin 是 null,
// 拿不到父页面的任何东西,也没有自己的 storage
// 故意不加 allow-top-navigation → 它不能把你的页面导航走
// 故意不加 allow-popups → 它不能弹窗
// 故意不加 allow-forms → 它不能提交表单
frame.srcdoc = pluginHtml;
document.body.appendChild(frame);
// 通信只能靠 postMessage
frame.contentWindow.postMessage({ t: 'init', data }, '*');
同时写 allow-scripts 和 allow-same-origin,等于没有沙箱。
因为有了 allow-same-origin,iframe 里的脚本和父页面同源,它可以直接 parent.document 拿到你的一切,还能自己把 sandbox 属性删掉。
浏览器控制台会对这个组合发警告,但很多人会忽略它 —— 通常是因为「我需要用 localStorage」或者「跨域报错了」,然后顺手加上 allow-same-origin 把沙箱拆了。
正确做法:需要存储就通过 postMessage 请宿主代劳,不要给它同源权限。
VS Code 的 Webview 就是这个方案的工业级实现。它给插件开了一个「可以画界面」的口子,但那个口子是一个受严格限制的 iframe:有自己的 CSP、资源要通过特殊的 URI scheme 加载、和插件之间只能 postMessage。
做法四:WASM(默认什么都没有)
前面三种都是「JS 环境,然后拿掉一些东西」。WASM 是另一个起点:一个 WASM 模块默认什么都做不了。
没有文件系统、没有网络、没有系统调用、没有时钟、甚至没有办法分配自己线性内存之外的内存。它能做的每一件事,都必须由宿主作为导入函数显式交给它。
const { instance } = await WebAssembly.instantiate(wasmBytes, {
host: {
// 插件能调的,只有这里列出的这几个 —— 一个不多
read_note: (idPtr, idLen) => { /* … */ },
log: (ptr, len) => { /* … */ },
}
});
instance.exports.activate();
| WASM 的优势 | WASM 的代价 |
|---|---|
| 默认零权限 —— 白名单模型做到极致 | 数据传递麻烦:只能传数字,字符串/对象要自己在线性内存里编解码 |
能限制内存上限(maximum 页数) | 调试体验比 JS 差很多 |
| 部分运行时能限制执行指令数(燃料) | 插件作者要用 Rust / C / Go / AssemblyScript 等,门槛更高 |
| 插件可以用多种语言写 | 生态和工具链还在成熟中 |
| 性能接近原生 | 不能直接碰 DOM,需要经过 JS 胶水层 |
什么时候该认真考虑 WASM:插件来自完全不可信的来源、插件干的是计算密集的活(解析、转换、算法)、并且你需要硬性的资源上限。典型场景是「让用户上传一段数据处理脚本」这类的 SaaS 功能。
什么时候不该:插件主要在做 UI 或调用大量宿主 API 时 —— 那样你会花 90% 的时间写胶水层,隔离的收益被开发成本吃掉。
一张选型表
| Proxy | Worker | iframe | 独立进程 | WASM | |
|---|---|---|---|---|---|
| 防误用 | ✓ | ✓ | ✓ | ✓ | ✓ |
| 防恶意读取 | ✗ | ✓ | ✓ | ✓ | ✓ |
| 防卡死 UI | ✗ | ✓ | 部分 | ✓ | ✗(除非有燃料) |
| 能强制终止 | ✗ | ✓ | ✓ | ✓ | 看运行时 |
| 限制内存 | ✗ | ✗ | ✗ | ✓(系统级) | ✓ |
| 插件能画 UI | ✓ | ✗ | ✓ | ✗ | ✗ |
| API 可同步 | ✓ | ✗ | ✗ | ✗ | ✓ |
| 实现成本 | 很低 | 中 | 中 | 高 | 中高 |
看倒数第二行 —— 这是隔离真正的成本所在。除了 Proxy 和 WASM,其它方案都会让你的 API 全部变成异步。这不只是加几个 await 的问题,它会改变插件作者的思维方式,也会让某些设计(比如「同步的文本转换钩子」)根本不可能。
大多数人应该走这条路,按顺序:
第一步(今天就做):干净的 ctx 设计 + 权限闸门 + 全异步 API。成本很低,能挡住 90% 的实际问题(误用、不知情越界、依赖内部实现),而且全异步 API 保住了你以后加隔离的可能。
第二步(当你有了不认识的插件作者):上 Worker。因为你的 API 已经是异步的了,这一步的迁移成本比想象中低得多 —— 插件代码甚至可能一个字都不用改。
第三步(当风险足够高):独立进程或 WASM。这一步要有明确的商业理由才值得。
不要做的事:跳过第一步直接搞复杂隔离(你还不知道要给插件什么能力),或者永远停在第一步却在文档里宣称有沙箱(第 14 章)。
装到自己身上
坏消息:JVM 上没有 Worker 这种便宜的隔离。你的选项是:
① 独立进程(Android 上最实际的方案)。在 AndroidManifest.xml 里给承载插件的 Service 指定 android:process=":plugin",然后用 AIDL / Messenger 通信:
<service
android:name=".PluginHostService"
android:process=":plugin"
android:exported="false" />
白得的好处:插件进程崩了主进程没事、可以 Process.killProcess() 掐死它、系统会单独统计它的内存、甚至可以给它更低的权限(如果用单独的 sharedUserId 和更少的 manifest 权限)。
代价:AIDL 的跨进程调用要序列化、Binder 事务有 1MB 大小限制(传大数据要用 ParcelFileDescriptor)、进程启动有开销。
② 嵌一个脚本引擎(QuickJS / Hermes / Lua)。插件用脚本写,宿主完全控制它能调什么。跨平台是额外红利。代价是插件作者得用另一门语言,而且性能不如原生。
③ 接受同进程,把力气花在别处。这是 Mihon 的选择,也是绝大多数 Android 应用的现实选择 —— 然后老老实实靠签名做可归责,靠 Dispatchers.IO 避免冻 UI,并且在文档里说清楚。
注意:SecurityManager 不是选项(第 14 章说过,它在 JDK 17 被废弃并已移除),Android 上也从来没有可用的等价物。
这一章的一句话
有效的隔离都是白名单式的 —— 换一个从一开始就没有你那些东西的环境,而不是在共享环境里堵路;判断一个方案好不好,就看「漏掉一个的后果」是全线失守还是少个功能。
下一章回到 Proxy 的主场:权限。我们把 Chrome 那套模型拆下来,装到自己的宿主上,让一个越权的插件当场被拒。