目标是在 Combine Field Mapping 中支持直接书写 JavaScript, 替代目前只能表达 if/else 的 flow 图。本文给出一个已经跑得起来的实现, 以及语法限制的三个候选方案,供评估与讨论。
逃逸靠的是对象可达性,不是语法结构 ——
erp.constructor.constructor('return process')() 里没有任何一个"危险关键字",
而 while(true){} 是百分之百合法语法却能拖垮服务。
因此语法黑名单既拦不住逃逸,也拦不住资源耗尽。
这不是说不要限制语法。而是要把语法限制的目的放对位置: 它服务的是可支持性、性能可预测性、降低客户犯错, 而不是安全性。安全性由沙箱负责。
如果团队形成"限制了语法所以就安全了"的印象,进而放松沙箱那几层约束, 那才是真正的风险来源。语法限制可以讨论、可以放宽,沙箱边界不能动。
现有的 mapping flow 是一个声明式节点图(start / setValue /
ifElse / return,10 个比较算子,多节点拼接)。
它的能力上限由算子数量决定,每加一种需求就要加新算子 ——
这实际上是在手搓一门更差的编程语言:没有 lint、没有测试、没有调试器,用户也不愿意学。
| 意见 | 评估 |
|---|---|
| 产品经理:允许直接写 JS,不要只处理 if/else | 方向正确。声明式 DSL 的表达力缺口是真实的,硬撑下去只会让算子表无限膨胀。 |
| 产品经理:但必须保证代码对系统安全 | 同样正确,而且这正好由沙箱解决 —— 不需要靠限制语法来实现。 |
| 产品经理(最新):应该限制一些语法,先出初步方案再讨论 | 合理,但目的需要澄清。见第 3 节,本文给出三个候选供选。 |
这不是纸面方案。沙箱、编辑界面、预览接口已经落地,并且用真实攻击代码实测过。
客户代码运行在 QuickJS(编译为 WebAssembly) 解释器里:
独立的堆,没有 require、process、fetch、定时器,
也没有任何宿主对象能进得去。跨进沙箱的只有 ERP 数据的 JSON 文本,
出来的只有 primitive。
JSON.parse 进入)| 攻击向量 | 结果 |
|---|---|
/^(a+)+$/ 灾难性回溯(ReDoS) | 拦下 · 103 ms 超时 |
"x".repeat(100000000) | 拦下 · out of memory |
| 百万级元素数组分配 | 拦下 · 超时 / OOM |
深层递归 f(n+1) | 拦下 · stack overflow(2 ms) |
require / process / fetch / Buffer / global / module / setTimeout | 全部 undefined |
Emscripten 胶水名 Module / ccall / HEAP8 / wasmMemory / importScripts / Asyncify | 全部 undefined |
原型链逃逸 erp.constructor.constructor('return process')() | 'process' is not defined |
Function 构造器取宿主 global | 拿到的是沙箱自己的 global |
闭合函数体包裹逃逸 }; globalThis.pwned = 1; (function(){ | 语法错误挡下 |
跨次调用残留(globalThis.stash 存上一租户数据) | 下一次看不到 |
原始实现把解释器栈设成 512 KB,高于 WebAssembly 模块自身的栈。
结果是深层递归先撑爆 WASM 栈,来不及被解释器发现,
最终以宿主 RangeError 形式抛给调用方,并留下一个无法正常释放的 runtime。
已改为 128 KB,现在是干净的 stack overflow 错误,且外层加了兜底 try/catch。
这个问题不是读代码发现的,是跑攻击代码发现的。
| 部分 | 状态 |
|---|---|
| 数据模型 | value_mode: 'table' | 'flow' | 'code',新增 value_code;三者共存,切换不丢配置 |
| 执行链路 | 接入 resolveMappingRowValue。所有 push 路径(Shopify / BigCommerce / order / customer)都收敛在这一个闸口 |
| 来源表抓取 | code 行无法静态得知要读哪些表,因此为其抓取全部来源表 |
| 编辑界面 | Mapping 弹窗新增 Code tab,且只在 Combine Mapping 出现(其他 8 处表格不生效,故不给入口) |
| 预览 | POST /products/mapping-code/preview,用 sample product 真实执行并返回结果 / 错误 / 耗时。需 JWT + ProductMapping 权限 |
| 启动预热 | main / processor / cron 三个入口各自预加载解释器 |
三个方案按"表达力"从窄到宽排列。推荐 方案 B, 但请先看第 5 节 —— 无论选哪个,限制的目的是可支持性与性能可预测性。
// 客户写的全部内容
erp.product.UnitPrice * 100
// 内部包成
(function (erp) { return <客户内容> })
&&/||、字符串方法、模板字符串、Math / Number / JSON 纯函数// 允许:多步计算、判空、分支
const price = Number(erp.product.UnitPrice);
if (!Number.isFinite(price)) return undefined;
if (erp.product.status !== 'A') return null;
return Math.round(price * 1.1 * 100) / 100;
// 禁止:循环(性能不可预测的唯一来源)
for (let i = 0; i < 1000000; i++) { ... } // ← 静态检查拒绝
while (true) { } // ← 静态检查拒绝
const / let、if/else、三元、算术、字符串与模板字符串、Math / Number / JSON、Date、小数组方法for / while / do / for...of / for...in)、class、new、async/await、import/export、eval/Function、标签语句、with、debuggermap / filter / reduce 是循环的化身,需要限制输入数组规模
把 flow 的算子表换成一组成熟纯函数(substring / split /
concat / dateAdd / round / lookup …),
只允许组合调用,不允许书写语句。
| 维度 | A 表达式 | B 受限函数体 | C 函数库 |
|---|---|---|---|
| 表达力 | 低 | 中高 | 中(取决于函数库) |
| 性能可预测 | 强(O(1)) | 强(禁循环) | 强 |
| 客户可读 / 可调试 | 中 | 好 | 好 |
| 是否满足"不要太严格" | 偏严 | 满足 | 不满足 |
| 需要新依赖 | 是(解析器) | 是(解析器) | 否 |
| 安全边界是否变化 | 三个方案都不改变沙箱边界 | ||
限制语法有两条互补的技术路径。二者正交,可以同时使用。
QuickJS 提供 ContextOptions.intrinsics,可以在创建沙箱时关闭内置对象。
这部分不需要任何新依赖:
| 关闭 | 效果 | 建议 |
|---|---|---|
RegExp | 消除 ReDoS 面(当前靠 100 ms 超时兜底) | 建议关闭 |
Eval | 关闭 JS 层 eval(宿主侧执行不受影响,需验证) | 建议关闭 |
Promise | 我们本来就不调度微任务 | 可关闭 |
Proxy | 少一类花招 | 可关闭 |
TypedArrays / BigInt / MapSet | 按是否会被真实映射用到决定 | 待定 |
intrinsics 只能关闭内置对象,关不掉 for / while 这类语法。 要禁止循环必须走静态检查。
用 JS 解析器把代码解析成 AST,按节点类型白名单放行。这是唯一可靠的做法 —— 用正则表达式做语法检查是错的,会既漏判又误判。
当前 typescript 只在 devDependencies,生产环境不可用。
需要引入运行时依赖,建议 acorn:纯 JS、零依赖、约 120 KB、ES 标准解析器(ESLint / Vite 也用它)。
如果团队不接受新依赖,方案 B 无法静态禁循环,只能退回方案 C 或接受循环 + 超时兜底。
检查时机(两处都要):
acorn 作为运行时依赖?
方案 A / B 的前提。不加就只剩方案 C 或"接受循环 + 超时兜底"。
ProductMapping 权限。若客户希望"只有管理员能写 JS",需要新权限位。
以下都是真实的、尚未处理的项。按建议处理顺序排列。
| # | 问题 | 说明 |
|---|---|---|
| 1 | 预览接口是上游放大器 | 每次调用都会读 sample product,在 P21 上会触发真实的上游 ERP 请求,且没有速率限制。有权限的人刷预览 = 刷 ERP。建议优先处理。 |
| 2 | 单次执行占用主线程 100 ms,且无速率限制 | 没有 worker 包裹,并发请求可以打满 CPU,影响所有租户的同步。方案:加速率限制,或把沙箱移入 worker(后者需要把 push 链路异步化)。 |
| 3 | 所有执行共用一个 WASM 模块 | QuickJS 的 runtime 在 JS 层互相隔离,但共用同一块线性内存和 Emscripten 堆。理论上一个 QuickJS 内存破坏缺陷能跨 runtime 影响。需要 QuickJS 的 0day 级漏洞,概率低;修法是每租户独立模块(内存开销大,且 v8 对 wasm 模块数量有硬限制)。 |
| 4 | 沙箱失败在 push 路径上是静默的 | 代码出错时结果被丢弃,错误信息没有进任何日志。客户只会看到"同步没生效"。若第 3 项开始出问题,也没有日志能暴露。建议与第 1、2 项一起处理。 |
| 5 | 来源数据全量注入,无体积上限 | code 行会把全部 ERP 表序列化进沙箱,没有大小检查。超大记录 × 多行代码 = 序列化放大。 |
| 6 | 保存时不校验代码 | 可以存入超长或语法错误的代码,运行时才失败(且静默)。应在保存时拒绝。 |
| 7 | MHD widget 有独立实现副本 | widgets/MHD/.../pushProduct 走的是自己那份转换逻辑,code 模式在 MHD 下不生效。是否需要同步取决于该客户。 |
需要说清楚:没有验证手段能"证明"沙箱无法逃逸。 已做的是针对已知攻击面逐条实测(见 3.2),不是形式化保证。没有做 fuzz,也没有逐条比对 QuickJS 的 CVE 列表。 采用 WASM 这个方案的核心理由是:即使 QuickJS 的 C 层存在漏洞,破坏也被限制在 WebAssembly 线性内存内。