三种方案的隔离强度、资源上限与落地代价对比,以及针对当前产品约束的推荐。
这三条约束决定了后面所有取舍,脱离它们谈"哪个更安全"没有意义:
isolated-vm 等所有 native 编译方案。resolveMappingRowValue → convertProductCustomMappingValue 全是同步签名。真正的分水岭不是"用哪个引擎",而是 guest 代码跑在跟 host 同一个堆里,还是一个物理独立的堆里。 这一条就预测了绝大部分安全性和失败模式。
独立堆 / Family A
QuickJS 引擎编译成 WebAssembly,拥有独立的线性内存与堆。宿主对象根本不进入沙箱 —— 数据以 JSON 文本穿过边界,在解释器内 JSON.parse 还原。
OS 进程边界 / Family B
把执行挪出主进程:child_process(进程池)或独立部署的 runner 服务,通过 IPC / HTTP 调用。可叠加 ulimit / cgroup / seccomp / 无网络命名空间。
同堆 + 冻结内建 / Family C
不隔离,而是冻结:lockdown() 冻结全部内建对象与原型,堵死通过原型链篡改的路径;Compartment 提供独立的 globalThis。
| 维度 | A · QuickJS-in-WASM | B · 独立进程 / 服务 | C · SES |
|---|---|---|---|
| 隔离家族 | 独立堆 | OS 进程 | 同堆加固 |
| 隔离强度 | 强 | 最强 | 中 |
| CPU 上限 | ✅ 引擎中断 | ✅ OS 强制 | ❌ 无 |
| 内存上限 | ✅ 可配置 | ✅ OS 强制 | ❌ 无 |
| 宿主对象可达 | 否(已实测) | 否 | 否(需收窄 API 面) |
| 依赖形态 | 纯 JS (WASM) | 无需额外依赖 | 纯 JS |
| 同步链路可用 | ✅ 预加载后 | ❌ 需异步化 | ✅ |
| 单次执行开销 | 1–2 ms | IPC 往返 | ≈ 0 |
| 跨租户风险 | 共享线性内存(理论) | 无 | 同进程 |
| 运维复杂度 | 低 | 高 | 低 |
| 生产先例 | Figma、CesiumJS | 通用做法 | MetaMask Snaps |
以下不是设计意图,是在 A1-API 上对攻击代码实跑的结果(41 个测试,其中含逃逸尝试):
挡住的 ────────────────────────────────────────────────
/^(a+)+$/ 灾难性回溯 → 103ms 超时拦下
"x".repeat(1e8) → out of memory
百万元素数组 → out of memory
require / process / fetch → 全部 undefined
/ Buffer / global / module
/ setTimeout / setInterval
/ XMLHttpRequest
Module / ccall / HEAP8 → 全部 undefined(Emscripten 胶水没漏)
/ wasmMemory / importScripts
/ Asyncify
erp.constructor.constructor
('return process')() → 'process' is not defined
闭合函数体逃出包裹 → 语法错误挡下
资源耗尽 ──────────────────────────────────────────────
function f(n){return f(n+1)} → 'stack overflow',2ms
while(true){} → 100ms 超时
跨次调用隔离 ──────────────────────────────────────────
上一次设的 globalThis.stash → 下一次读不到
上一次的 erp.product → 下一次读不到
崩溃(栈溢出)之后 → 沙箱仍可用,下一条 1ms 正常
关于栈限制的一个坑。最初把解释器栈设为 512KB,递归没有返回干净错误,而是把宿主的 RangeError 抛到了调用方,并导致运行时无法释放。原因是 512KB 超过了 WASM 模块自身的栈 —— 递归先撑爆 WASM 栈,解释器还来不及发现自己的栈满了。
降到 128KB 后正常。但这是 workaround,不是根治:同类实现 quickjs-wasi 被明确列为"不推荐",理由第一条就是这个 —— stack overflow crashes the whole WASM instance instead of throwing a catchable exception。这是架构固有弱点,不是某个库的实现缺陷。谁调大这个值,问题就会回来。
已知的共享点。所有执行共用一个 WASM 模块,即共用同一块线性内存和 Emscripten 的 malloc 堆。QuickJS 的 runtime 在 JS 层互相隔离,但内存层同源。所以理论上,QuickJS 的 use-after-free 或堆越界 bug 能让一个租户的 runtime 破坏另一个的数据 —— 这是跨租户唯一可能的路径。
需要 0day 级漏洞才能利用,不是配置错误就会发生的那种。隔离更强的做法是每租户一个 WASM 模块,代价是每个约 1MB+ 内存,且 v8 对同时存在的 wasm 模块数有硬限制。
SES 的设计目标是把可攻击面消除干净,它做得很成功 —— Figma 早期、MetaMask Snaps 都用这条路。但它的官方立场是:资源限制由外层边界负责,SES 自己不做。
对"客户自己在 UI 里写代码"这个场景,这是致命的。while(true){} 不是攻击,是手滑 —— 一个客户写错一行,就能让整个进程停摆。没有 CPU 上限的方案在这里不能接受。
B 在安全上是最优的,但它要求把执行挪出主进程,而现有 push 链路是纯同步的 —— 从取值到转换,没有一个 await。
改成跨进程意味着异步化整条链路:每个调用点、每层调用栈都要改。这个成本不是安全投入,是架构改造,应该由真实需求驱动,而不是"顺手做得更安全"。
另外要注意:worker_threads 虽然轻,但不是安全边界(同进程内存),只能作为纵深防御的一层,不能替代进程边界。
保持 A,把 B 作为下一步的边界升级路径,C 不采用。
值得偶尔重新问一次:客户真的需要任意 JS 吗?
| 方案 | 特点 | 代价 |
|---|---|---|
| Jsonata | 专为 JSON 映射设计的查询与转换语言,纯 JS 实现 | 表达力有上限,但覆盖绝大多数映射场景 |
| CEL | Google 的 Common Expression Language,非图灵完备,可静态验证(K8s、Firebase Rules 在用) | 无循环,复杂逻辑表达不了 |
这条路不需要沙箱,因为压根不执行任意代码。而且"可静态验证"这点特别值钱 —— 能在保存时就告诉客户"这段逻辑永远返回空",而不是等推送时静默失败。
代价是回到"太严格"的方向,但要和现有 flow 节点图区分开:Jsonata / CEL 是真语言,有工具链、能 lint、能写测试,和手搓一门更差的 DSL 不是一个层次。