Product Mapping · Combine Field Mapping

允许直接写 JS:方案与语法限制选项

目标是在 Combine Field Mapping 中支持直接书写 JavaScript, 替代目前只能表达 if/else 的 flow 图。本文给出一个已经跑得起来的实现, 以及语法限制的三个候选方案,供评估与讨论。

初步方案 · v0.1 · 待讨论 沙箱已实现并实测 代码未提交,在当前分支 2026年10月9日

01一句话结论

沙箱是安全边界,语法限制不是

逃逸靠的是对象可达性,不是语法结构 —— erp.constructor.constructor('return process')() 里没有任何一个"危险关键字", 而 while(true){} 是百分之百合法语法却能拖垮服务。 因此语法黑名单既拦不住逃逸,也拦不住资源耗尽。

这不是说不要限制语法。而是要把语法限制的目的放对位置: 它服务的是可支持性、性能可预测性、降低客户犯错, 而不是安全性。安全性由沙箱负责。

需要避免的共识偏差

如果团队形成"限制了语法所以就安全了"的印象,进而放松沙箱那几层约束, 那才是真正的风险来源。语法限制可以讨论、可以放宽,沙箱边界不能动。

02背景

现有的 mapping flow 是一个声明式节点图(start / setValue / ifElse / return,10 个比较算子,多节点拼接)。 它的能力上限由算子数量决定,每加一种需求就要加新算子 —— 这实际上是在手搓一门更差的编程语言:没有 lint、没有测试、没有调试器,用户也不愿意学。

意见评估
产品经理:允许直接写 JS,不要只处理 if/else 方向正确。声明式 DSL 的表达力缺口是真实的,硬撑下去只会让算子表无限膨胀。
产品经理:但必须保证代码对系统安全 同样正确,而且这正好由沙箱解决 —— 不需要靠限制语法来实现。
产品经理(最新):应该限制一些语法,先出初步方案再讨论 合理,但目的需要澄清。见第 3 节,本文给出三个候选供选。

03已经实现并实测的部分

这不是纸面方案。沙箱、编辑界面、预览接口已经落地,并且用真实攻击代码实测过。

3.1 沙箱

客户代码运行在 QuickJS(编译为 WebAssembly) 解释器里: 独立的堆,没有 require、process、fetch、定时器, 也没有任何宿主对象能进得去。跨进沙箱的只有 ERP 数据的 JSON 文本, 出来的只有 primitive。

硬性限制

执行时长
100 ms(wall clock,中断处理器强制)
解释器堆
8 MB
解释器栈
128 KB
代码长度
20,000 字符
失败模式
字段级隔离:一行失败只影响这一行,不阻塞整批推送

能力边界

宿主对象
零注入(数据以 JSON 文本经 JSON.parse 进入)
网络
无
文件
无
异步
无(不支持 await / Promise 调度)
隔离单位
每次执行新建 runtime + context,用完即弃

3.2 实测结果(真实攻击代码,非推测)

攻击向量结果
/^(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。 这个问题不是读代码发现的,是跑攻击代码发现的。

3.3 工程接入面

部分状态
数据模型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 三个入口各自预加载解释器

04语法限制:三个候选方案

三个方案按"表达力"从窄到宽排列。推荐 方案 B, 但请先看第 5 节 —— 无论选哪个,限制的目的是可支持性与性能可预测性。

方案 A — Expression Only(最严格) 只允许一个表达式,没有 return
// 客户写的全部内容
erp.product.UnitPrice * 100

// 内部包成
(function (erp) { return <客户内容> })
允许
算术、三元、&&/||、字符串方法、模板字符串、Math / Number / JSON 纯函数
禁止
变量声明、if 语句、循环、函数定义、赋值、正则
实现
AST 检查确认是单个表达式;无需额外运行时约束

优点

  • 耗时与输入规模无关,O(1) 有保证
  • 可静态分析,未来能在前端直接预览
  • UI 可做成"公式输入框",非技术人员也能改

缺点

  • 多步计算写不出来,只能嵌套,可读性差
  • 遇到"先取字段、判空、再运算"这类需求就会不够用
方案 B — Restricted Function Body 推荐 保留 return、局部变量、if/else;禁止循环
// 允许:多步计算、判空、分支
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、debugger
实现
AST 遍历,按节点类型白名单;保存时拒绝 + 运行时二次校验

优点

  • 覆盖绝大多数真实映射需求
  • 禁止循环 ⇒ 单行耗时与数据量无关,100 ms 超时几乎不会触发
  • 代码短、可读,支持成本可控
  • 符合"不要太严格"的诉求

缺点 / 需要注意

  • 需要引入 JS 解析器依赖(见第 5 节)
  • map / filter / reduce 是循环的化身,需要限制输入数组规模
  • 递归仍可能写出 —— 靠 128 KB 栈限制兜底(已实测能干净拦下)
方案 C — 白名单函数库 不用 JS 语法,回到声明式 + 纯函数组合

把 flow 的算子表换成一组成熟纯函数(substring / split / concat / dateAdd / round / lookup …), 只允许组合调用,不允许书写语句。

优点

  • 零逃逸面,零资源耗尽面
  • 可可视化编辑、可审计、可 diff
  • 无支持成本黑洞

缺点

  • 正是产品经理反对的方向 —— 依然"太严格"
  • 函数库会持续膨胀,最终仍然变成一门语言(只是更差的)

方案对比

维度 A 表达式 B 受限函数体 C 函数库
表达力低中高中(取决于函数库)
性能可预测强(O(1))强(禁循环)强
客户可读 / 可调试中好好
是否满足"不要太严格"偏严满足不满足
需要新依赖是(解析器)是(解析器)否
安全边界是否变化三个方案都不改变沙箱边界

05怎么实现语法限制

限制语法有两条互补的技术路径。二者正交,可以同时使用。

5.1 运行时能力限制(零依赖,可立即用)

QuickJS 提供 ContextOptions.intrinsics,可以在创建沙箱时关闭内置对象。 这部分不需要任何新依赖:

关闭效果建议
RegExp消除 ReDoS 面(当前靠 100 ms 超时兜底)建议关闭
Eval关闭 JS 层 eval(宿主侧执行不受影响,需验证)建议关闭
Promise我们本来就不调度微任务可关闭
Proxy少一类花招可关闭
TypedArrays / BigInt / MapSet按是否会被真实映射用到决定待定
局限

intrinsics 只能关闭内置对象,关不掉 for / while 这类语法。 要禁止循环必须走静态检查。

5.2 静态 AST 检查(方案 A / B 需要)

用 JS 解析器把代码解析成 AST,按节点类型白名单放行。这是唯一可靠的做法 —— 用正则表达式做语法检查是错的,会既漏判又误判。

依赖决策点

当前 typescript 只在 devDependencies,生产环境不可用。 需要引入运行时依赖,建议 acorn:纯 JS、零依赖、约 120 KB、ES 标准解析器(ESLint / Vite 也用它)。 如果团队不接受新依赖,方案 B 无法静态禁循环,只能退回方案 C 或接受循环 + 超时兜底。

检查时机(两处都要):

5.3 已有护栏(已实现,与上面正交)

06待决策清单

  1. 语法限制选 A / B / C 哪一个? 推荐 B:满足"不要太严格",同时用"禁循环"换来性能可预测。
  2. 能否引入 acorn 作为运行时依赖? 方案 A / B 的前提。不加就只剩方案 C 或"接受循环 + 超时兜底"。
  3. 循环禁止到什么程度?数组方法(map / filter / reduce)是否也禁? 它们是循环的化身。若允许,需要限制输入数组规模;若禁止,表达力会明显下降。
  4. 正则表达式:完全关闭,还是保留 + 超时兜底? 关闭能消除一类支持问题;保留能让部分客户迁移更顺。实测超时已能拦住 ReDoS。
  5. 代码的版本 / 审计 / 回滚,现在做还是以后做? 客户在生产环境写代码,改坏了要能回退。这是治理需求,不是安全需求。
  6. "谁能写代码"是否需要独立权限门? 当前复用 ProductMapping 权限。若客户希望"只有管理员能写 JS",需要新权限位。
  7. 预览接口的速率限制 —— 建议独立于语法限制,优先处理。 见第 7 节风险 1,它是当前最实际的风险项。

07已知风险与当前局限

以下都是真实的、尚未处理的项。按建议处理顺序排列。

#问题说明
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 的补充说明

需要说清楚:没有验证手段能"证明"沙箱无法逃逸。 已做的是针对已知攻击面逐条实测(见 3.2),不是形式化保证。没有做 fuzz,也没有逐条比对 QuickJS 的 CVE 列表。 采用 WASM 这个方案的核心理由是:即使 QuickJS 的 C 层存在漏洞,破坏也被限制在 WebAssembly 线性内存内。

08验证状态

测试
179 passed / 13 suites(含 13 个逃逸与资源耗尽测试、4 个跨连接隔离测试)
类型
前端 0 错误;后端仅剩 3 个既有报错(SawBone widget spec,与本次无关)
Lint
本次改动文件全部干净
未做
未做真机端到端(需要起服务 + 数据库);未做多进程实测;未做性能压测
改动规模
新增 2 文件(沙箱 + 测试),修改 16 文件,约 390 行