客户自定义映射:JS 执行方案选型

三种方案的隔离强度、资源上限与落地代价对比,以及针对当前产品约束的推荐。

2026 年 10 月 9 日 · 基于 A1-API 的 Combine Mapping 试点实测

决策前提

这三条约束决定了后面所有取舍,脱离它们谈"哪个更安全"没有意义:

三个方案

真正的分水岭不是"用哪个引擎",而是 guest 代码跑在跟 host 同一个堆里,还是一个物理独立的堆里。 这一条就预测了绝大部分安全性和失败模式。

方案 A · 当前实现

QuickJS-in-WASM

独立堆 / Family A

QuickJS 引擎编译成 WebAssembly,拥有独立的线性内存与堆。宿主对象根本不进入沙箱 —— 数据以 JSON 文本穿过边界,在解释器内 JSON.parse 还原。

  • 唯一同时提供真隔离和资源上限的选项
  • 纯 JS 依赖,无编译工具链
  • 预加载后可在同步链路中直接调用
适合:客户写任意 JS + 需要硬性资源上限 + 不能大改链路。
方案 B · 边界升级

独立进程 / Runner 服务

OS 进程边界 / Family B

把执行挪出主进程:child_process(进程池)或独立部署的 runner 服务,通过 IPC / HTTP 调用。可叠加 ulimit / cgroup / seccomp / 无网络命名空间。

  • 边界最硬:即使 JS 引擎被攻破,仍在没有凭据、没有 DB、没有网络的进程里
  • 资源限制由 OS 强制,不依赖引擎自省
  • 是通用做法,不绑定任何引擎
代价:必须把整条 push 链路异步化 —— 这是架构改造,不是安全投入。
方案 C · 不建议

SES / lockdown

同堆 + 冻结内建 / Family C

不隔离,而是冻结:lockdown() 冻结全部内建对象与原型,堵死通过原型链篡改的路径;Compartment 提供独立的 globalThis。

  • 纯 JS,性能接近原生,有完整 devtools 支持
  • 无序列化开销,可直接传对象引用
  • 被 MetaMask Snaps 用在生产环境
硬伤:没有内建的 CPU / 内存上限,官方立场是靠外层边界补 —— 见下文。

正面对比

维度 A · QuickJS-in-WASM B · 独立进程 / 服务 C · SES
隔离家族独立堆OS 进程同堆加固
隔离强度强最强中
CPU 上限✅ 引擎中断✅ OS 强制❌ 无
内存上限✅ 可配置✅ OS 强制❌ 无
宿主对象可达否(已实测)否否(需收窄 API 面)
依赖形态纯 JS (WASM)无需额外依赖纯 JS
同步链路可用✅ 预加载后❌ 需异步化✅
单次执行开销1–2 msIPC 往返≈ 0
跨租户风险共享线性内存(理论)无同进程
运维复杂度低高低
生产先例Figma、CesiumJS通用做法MetaMask Snaps

方案 A 实测记录

以下不是设计意图,是在 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 模块数有硬限制。

方案 C 为什么不行

SES 的设计目标是把可攻击面消除干净,它做得很成功 —— Figma 早期、MetaMask Snaps 都用这条路。但它的官方立场是:资源限制由外层边界负责,SES 自己不做。

对"客户自己在 UI 里写代码"这个场景,这是致命的。while(true){} 不是攻击,是手滑 —— 一个客户写错一行,就能让整个进程停摆。没有 CPU 上限的方案在这里不能接受。

方案 B 的真实代价

B 在安全上是最优的,但它要求把执行挪出主进程,而现有 push 链路是纯同步的 —— 从取值到转换,没有一个 await。

改成跨进程意味着异步化整条链路:每个调用点、每层调用栈都要改。这个成本不是安全投入,是架构改造,应该由真实需求驱动,而不是"顺手做得更安全"。

另外要注意:worker_threads 虽然轻,但不是安全边界(同进程内存),只能作为纵深防御的一层,不能替代进程边界。

推荐

保持 A,把 B 作为下一步的边界升级路径,C 不采用。

  1. 只有 A 同时满足三条约束:纯 JS 依赖、客户自己写需要资源上限、同步链路不改。
  2. C 缺 CPU 上限,对客户自己写代码是硬伤 —— 安全性再好,也挡不住一行手滑把进程锁死。
  3. B 是最强的,但代价是异步化整条链路。应该等真实需求(客户需要更长的逻辑 / 合规要求更硬的隔离)再上。
  4. 如果将来做 B,A 不会浪费 —— QuickJS 会变成进程内的第二层防御,而不是唯一的墙。这也是行业共识的最强形态:加固的 JS 引擎 + 受限 worker + OS 级沙箱,三层叠加。

附:还有第三条路 —— 换表达方式

值得偶尔重新问一次:客户真的需要任意 JS 吗?

方案特点代价
Jsonata 专为 JSON 映射设计的查询与转换语言,纯 JS 实现 表达力有上限,但覆盖绝大多数映射场景
CEL Google 的 Common Expression Language,非图灵完备,可静态验证(K8s、Firebase Rules 在用) 无循环,复杂逻辑表达不了

这条路不需要沙箱,因为压根不执行任意代码。而且"可静态验证"这点特别值钱 —— 能在保存时就告诉客户"这段逻辑永远返回空",而不是等推送时静默失败。

代价是回到"太严格"的方向,但要和现有 flow 节点图区分开:Jsonata / CEL 是真语言,有工具链、能 lint、能写测试,和手搓一门更差的 DSL 不是一个层次。