轻易云
注册体验

isolated-vm × child_process 双层沙箱:脚本执行的绝对安全是怎么炼成的

· AI 财务对账· 12 次浏览· 约 12 分钟读完
isolated-vmchild_processNode.js 队列安全隔离V8 Context

isolated-vm × child_process 双层沙箱:脚本执行的绝对安全是怎么炼成的

一、一行失控代码能把对账系统拖垮到什么程度

2025 年某 SaaS 公司对账平台出过一起事故:业务人员上传了一个解析脚本,本意是把"账户流水月报.csv"中的"交易号"列映射成 businessOrderNo。脚本里有一句看似无害的代码——while (true) { ... },模块级死循环。这个脚本进了生产沙箱后,沙箱只设置了脚本级超时,没有进程级兜底,子进程把宿主机的 8 个 CPU 核心全部吃满,整整 7 分钟没有响应。

事故复盘得出三个教训:第一,脚本级超时拦不住 V8 死循环(V8 在死循环里根本无暇检查 timer);第二,单层沙箱(无论 V8 隔离多强)都不能兜住所有边界;第三,对账系统的沙箱与电商主营系统在同一进程域内,沙箱逃逸等于核心财务链路暴露。

这就是为什么任何严肃的脚本沙箱设计都不能只靠"V8 隔离"这一层。哪怕你有最好的 V8 isolate,也得再叠一层进程级隔离——把"沙箱失控"和"主进程崩溃"之间隔一道物理墙。

我们用一句话定下基线:

V8 实例级隔离解决"脚本读到什么",进程级隔离解决"脚本失控时主进程还活着吗"。

这就是业内反复出现的"双层沙箱"提法——isolated-vm × child_process。本文要讲清楚的是:双层沙箱的工程边界、4 道防线的代码落地、以及容易踩的 4 个真实陷阱。

二、双层沙箱到底在防什么

我们先把"沙箱要拦的事"列清楚,再讲怎么拦。

2.1 对账沙箱面临的威胁模型

电商对账系统的用户脚本(解析、对账、分摊、集成转换四类)允许用户做大量业务字段映射。但用户脚本不可信:

威胁典型攻击/失误单层隔离能做到吗
读取主进程环境变量(DB URL、JWT Secret)process.envV8 隔离能拦
require('child_process').exec('curl ...')子进程逃逸V8 隔离能拦(require 被禁)
while (true) {} 模块级死循环业务人员误写拦不住(V8 在循环里无暇响应 timer)
大内存分配 new Array(1e9) OOM业务脚本误循环拦不住(V8 同进程 OOM 拖垮父)
setTimeout 递归爆栈业务脚本回调地狱部分能拦(取决于 V8 版本)
抛出未捕获异常 → V8 异常逃逸业务脚本未 try/catch拦不住(V8 异常隔离是 promise 边界)

可以看出,前两条单层 V8 隔离就能防住——只要禁用 require/process/Buffer/fetch 这些全局变量,沙箱里跑的就是"无文件系统、无网络、无 shell"的纯 JS 环境。但后四条全是 V8 隔离的盲区:死循环、OOM、递归异常,全是 V8 实例本身在失控。

所以双层沙箱的本质是:V8 隔离解决"逻辑层",进程隔离解决"运行时层"。

2.2 双层沙箱架构全景

下面的双层沙箱架构:

轻易云沙箱机制架构图:主进程 Node.js 调度 Parse 沙箱 (V8 Context 隔离) 与 Script 沙箱 (child_process),双层隔离机制保证业务脚本既灵活又安全

这张图把整个沙箱体系拆成两个并列分支:

  • Parse 沙箱:以行为单位处理账单解析。每批最多 100 行(batchSize = 100),由父进程 fork 一个子进程跑完即退;V8 Context 隔离靠 Node.js 内置的 vm.createContext 实现,全局禁用 require / process / fs / http / Buffer / fetch,只暴露 JSON / Math / Date / Decimal / query 等安全子集。
  • Script 沙箱:对账脚本、费用分摊脚本、集成转换脚本——逻辑更重、上下文更多,跑的是批次级单次执行(一次 fork = 一次脚本执行),不拆行拆批;同样走 V8 Context 隔离 + 进程隔离,区别在超时常量(集成转换 30 秒,比 parse 60 秒更短,因为转换必须实时返回给前端预览)。

两个分支共用同一道父进程兜底——child.kill('SIGKILL') 强制终止 + stderr 前 500 字符异常捕获 + IPC 失败兜底。

来看一次完整的 Parse 沙箱执行流程:

轻易云解析沙箱执行流程图:从脚本编写到 BillRow 解析的 5 步链路,展示 V8 Context 隔离 + 30 秒闸的完整生命周期

这张流程图把 Parse 沙箱的 5 个生命周期节点摆开:脚本编写 → V8 Context 隔离 → 用户脚本执行 → 结果归一化 → BillRow 落库。每个节点都是独立的失败点,沙箱的责任是把"失控"卡在前 3 步,不让它污染到第 4 步之后的状态机。

三、4 道防线的代码落地(最关键的部分)

轻易云智能对账系统的双层沙箱不是抽象口号,是落在代码里的 4 道防线。本节要讲清楚的是:这 4 道防线分别在代码的哪一层、起什么作用?

防线 1 │ child_process.fork         进程级隔离(物理墙)
防线 2 │ vm.createContext           V8 上下文隔离(全局变量层)
防线 3 │ vm.Script.runInContext { timeout: 5000 }   编译期/顶层 5 秒兜底
防线 4 │ setTimeout → child.kill('SIGKILL')  整批超时强制终止

下面这 4 段代码选自集成转换沙箱宿主与子进程两端(集成转换沙箱是 Script 沙箱家族里最年轻的实例,覆盖了最完整的代码骨架)。

防线 1:进程级隔离(fork 子进程)

typescript
// transform-sandbox-runner-host.ts:232-241
let child: ChildProcess;
try {
  child = fork(getRunnerScriptPath(), [], {
    stdio: ["pipe", "pipe", "pipe", "ipc"],
    serialization: "advanced",
  });
} catch (err) {
  reject(new Error(`沙箱启动失败: ${(err as Error).message}`));
  return;
}

注意 3 个细节:

  1. stdio: ["pipe", "pipe", "pipe", "ipc"]——四个 stdio 流全 pipe 出去。ipc 通道用于父进程与子进程交换结构化数据(不是字符串),所以同时设置 serialization: "advanced"。这样子进程崩溃时父进程能拿到 stdout/stderr/IPC 三个独立信号源。
  2. getRunnerScriptPath() 三候选回退——dev 模式 __dirname 在 dist/,vitest 从 src/ 跑,自动回退到编译产物。这是工程稳健性的细节,不在 README 里写出来,踩过坑才懂。
  3. fork 失败立即 reject——子进程连启动都失败,没必要继续等。失败语义通过 Promise reject 暴露给上层 JobTask,触发状态机 FAILED。

防线 2 + 3:V8 Context 隔离 + 编译期 5 秒兜底

这是子进程内部的事(transform-sandbox-runner.ts:358-407):

typescript
async function processOnce(input: TransformSandboxInput): Promise<TransformSandboxResult> {
  const context = vm.createContext({
    ...ALLOWED_GLOBALS,         // JSON/Math/Decimal/query 等安全子集
    query: { /* 只读数据出口 */ },
  });

  // ESM 不被 vm.Script 解析 → 包 IIFE + export default → return 改写
  const transformed = input.scriptCode.replace(
    /^\s*export\s+default\s+/m,
    "return ",
  );
  const wrapped = `(function() {\n${transformed}\n})()`;

  let script: vm.Script;
  try {
    script = new vm.Script(wrapped, {
      filename: "user-transform-script.js",
    });
  } catch (err) {
    return { error: `脚本编译失败: ${(err as Error).message}` };
  }

  let transformFn: (input: unknown) => unknown;
  try {
    const compiled = script.runInContext(context, {
      timeout: 5000,           // 防线 3:顶层求值 5 秒兜底
      displayErrors: true,
    });
    if (typeof compiled !== "function") {
      return { error: `脚本必须 export default 一个 transform 函数(编译结果:${typeof compiled})` };
    }
    transformFn = compiled as (input: unknown) => unknown;
  } catch (err) {
    return { error: `脚本执行失败: ${(err as Error).message}` };
  }

  try {
    const result = await transformFn(input.input);
    return { output: normalizeOutput(result) };
  } catch (err) {
    return { error: `脚本异常: ${(err as Error).message}` };
  }
}

4 个关键细节:

  • ALLOWED_GLOBALS 白名单——这是 V8 Context 隔离的核心。除了 JSON / Math / Date / Decimal / RegExp / Map / Set / parseInt 这些纯计算 API,还显式 require: undefined / process: undefined / Buffer: undefined / fetch: undefined——即"故意挂上 undefined",让用户脚本里的 process.env 立即拿到 undefined,而不是 ReferenceError。console 还做了个 shim,写入子进程 stderr(父进程 stdio 是 pipe,能收到),防止 AI 生成的脚本里 console.log 直接抛 ReferenceError 判 FAILED。
  • vm.createContext 是 Node.js 原生 V8 Context 隔离——每个 Context 有独立的 globalThis、全局变量互不污染。这是"isolated-vm"产品定位的工程落地——绕开 native binding 依赖,用 Node.js 自带能力实现同等隔离。这条经验来自 v3 重构的「去过度设计」决策:能不发明就不发明,能用标准库就用标准库。
  • timeout: 5000 只拦截 IIFE 顶层求值——这是防线 3 的边界。它能拦住"模块级死循环",但拦不住用户脚本里 transform(input) 函数内部调用 query 后产生的死循环(那部分代码进入了 await transformFn(...),不在 vm 超时管辖范围)。所以必须靠防线 4 兜底。
  • normalizeOutput 归一化——对返回值做类型校验:缺 targetSystem 报错、documents 不是数组报错、entries 不是数组报错。这一步是契约强制,不允许脚本返回"差不多对"的松散对象。

防线 4:整批超时强制终止(最容易被忽视的兜底)

typescript
// transform-sandbox-runner-host.ts:243-246
const timer = setTimeout(() => {
  child.kill("SIGKILL");
  reject(new Error(`沙箱执行超时(${timeoutMs}ms),已强制终止`));
}, timeoutMs);

这一段只有 4 行,但藏着 4 个陷阱:

  1. SIGKILL 而非 SIGTERM——SIGTERM 给子进程一个 cleanup 机会,但用户脚本可能注册了自己的 process.on('SIGTERM', ...) 拦截,反而让超时检测失真。SIGKILL 是 Linux 内核强制杀进程,用户脚本拦不住。代价是子进程没有 cleanup 机会,但对于沙箱来说无所谓——子进程本来就不应该持久化任何状态。
  2. 超时常量 30 秒(集成转换)——比 parse 沙箱的 60 秒更紧。集成转换是用户前端实时预览触发的,必须 30 秒内返回;解析沙箱是后台异步任务,可以容忍 60 秒。这条边界写在 executeTransformInSandbox 的默认参数:const { timeoutMs = 30_000 } = options;。
  3. stderr 前 500 字符——超时 / 崩溃时,错误消息里会塞 stderr=${stderrBuf.slice(0, 500)}。这 500 字符是排查问题的关键证据。Node.js stderr 默认是 line-buffered,子进程死循环时也会被 V8 panic 或系统 OOM killer 写一行到 stderr,足够定位"是死循环还是 OOM"。
  4. stdout 兜底 + exit 兜底——child.on('exit') 监听子进程退出。如果 stdout 里有内容,先尝试 JSON.parse(stdoutBuf)(兼容 IPC 通道偶尔失败的极端场景);解析失败则按 signal === 'SIGKILL' / code !== 0 / "沙箱未返回结果" 三档 reject。最后一行兜底注释特别值得看——"正常路径已由 message 事件 settle,此处 reject 为幂等 no-op"——这是处理"退出码 0 但 IPC 消息丢失"的反直觉场景:不能让 Promise 永不 settle。

四、Parse 沙箱 vs Script 沙箱:4 个真实差异

容易踩的坑:以为两类沙箱"差不多"。它们的工程差异至少 4 处:

差异 1:拆批 vs 单次执行

Parse 沙箱每 100 行一批(batchSize = 100),一次 fork 跑完 100 行即退,由 executeParseBatchInSandbox 内的 reduce 串行处理多批。Script 沙箱(reconcile / allocate / transform)一次 fork = 一次脚本执行,不拆批;脚本异常 = 整批失败。这条差异决定了:脚本调试时 transform 比 parse 更容易定位错在哪一行,因为 transform 一次只跑一份数据。

差异 2:超时常量

沙箱类型默认 timeoutMs业务约束
Parse60_000 (60s)后台异步,可容忍较长
Reconcile60_000 (60s)后台异步,但更重的算法
Allocate60_000 (60s)后台异步,分摊计算开销大
Transform30_000 (30s)前端实时预览,必须秒级返回

batchSize 也差异明显:parse 默认 100 行,allocate 默认 20 个聚合。差异来自"单条记录的计算开销"——parse 是字段映射,allocate 是公摊分摊数学计算。

差异 3:AbortSignal 支持

只有 Parse 沙箱支持外部信号取消:

typescript
// parse-scripts/sandbox-runner-host.ts:217-229
const onAbort = () => {
  clearTimeout(timer);
  child.kill("SIGKILL");
  reject(new TaskCancelledError());
};
if (signal) {
  if (signal.aborted) {
    child.kill("SIGKILL");
    reject(new TaskCancelledError());
    return;
  }
  signal.addEventListener("abort", onAbort, { once: true });
}

这意味着用户在 BullMQ Worker 任务里点"停止",能立即 SIGKILL 当前子进程并 reject TaskCancelledError(与超时错误区分)。reconcile / allocate / transform 没有这条优化——它们的"取消"语义由 JobTask 状态机兜底,不直接干预子进程。

差异 4:返回值归一化严格度

Parse 沙箱返回的是行级数组,每行 10+ 个字段都是可选(缺啥就不写),归一化逻辑松散。Transform 沙箱返回值必须含 targetSystem(非空字符串)+ documents(数组)+ 每个 documents[i] 必须含 docType / head / entries——任何一项不合法都 reject。这是业务决定的:parse 是字段映射,缺一两个字段不影响后续对账;transform 是要推送金蝶的单据,缺一个字段就报错。

集成转换作为 Script 沙箱最复杂的应用场景,它的完整流程值得单独看一眼:

轻易云集成转换完整流程图:从对账结果已确认、生成批次、Script 沙箱执行转换、转换单据预览到推送金蝶云星空的 5 阶段链路

这张图把 Script 沙箱在集成转换里的角色讲清楚:对账结果(已 CONFIRMED)→ 生成批次(TFB-YYYYMMDD-NNNN)→ 转换沙箱执行(30s 闸,SIGKILL 兜底)→ 转换单据预览(transform_documents 头表 + 体行表 + payload JSONB)→ 金蝶推送(HMAC 双签名)。整个链路的瓶颈在第 3 步——Script 沙箱必须 30 秒内返回,否则前端预览就超时了。这就是为什么 transform 沙箱比 parse 沙箱的超时更紧。

五、4 个真实陷阱(生产环境踩过才写得出来)

陷阱 1:把 console.log 当 stdout 调试

子进程的 console.log 不写入 stdout,而是 shim 到 process.stderr.write。如果你在生产环境用 console.log 给业务人员展示解析进度,会发现前端什么都看不到——日志全在父进程的 stderr 里,得从 JobTask 的 errorMessage 字段间接拼出来。

正确做法:在 ALLOWED_GLOBALS.console shim 里把 log 重定向到 IPC 通道,或者用沙箱内的 query.log(...) 受控 API。我们当前是 shim 到 stderr——简单但够用,前端不依赖沙箱内日志。

陷阱 2:runInContext 的 timeout 拦不住 async 函数死循环

typescript
const compiled = script.runInContext(context, {
  timeout: 5000,  // 只拦截 IIFE 顶层求值
});

如果脚本 export default async function transform(input) { while (true) { ... } },runInContext 立刻返回 Promise(因为 async 函数立即返回 Promise),timeout: 5000 看着无害,实际 0 秒后就进入 await transformFn(...),5 秒防线完全失效。所以必须靠防线 4 的 setTimeout → SIGKILL 兜底。

陷阱 3:vm.Script 不支持 ESM

typescript
// 把 export default 改写为 return,让 IIFE 返回 transform 函数
const transformed = input.scriptCode.replace(
  /^\s*export\s+default\s+/m,
  "return ",
);
const wrapped = `(function() {\n${transformed}\n})()`;

vm.Script 只接受 CommonJS 风格的脚本体。如果用户写 import x from 'y',直接编译失败。regex 只匹配 export default,不处理 import / export const。这是已知边界——业务团队写脚本时必须用 CommonJS 风格或纯 ESM 函数体(无 import)。

陷阱 4:stdout 兜底是单点脆弱

typescript
// transform-sandbox-runner-host.ts:283-307
child.on("exit", (code, signal) => {
  clearTimeout(timer);
  if (stdoutBuf.trim()) {
    try {
      const parsed = JSON.parse(stdoutBuf) as TransformSandboxResult;
      // ... 解析成功直接 resolve
    } catch {
      /* fallthrough */
    }
  }
  // SIGKILL / code !== 0 / 兜底 reject
});

stdout 兜底是为了兼容 IPC 通道偶发不可用。但 stdout 是大字符串、无结构化、还可能被用户脚本里的 process.stdout.write 污染(虽然 ALLOWED_GLOBALS 没暴露 process,但子进程内的 console.log shim 写的是 stderr,stdout 不受沙箱控制)。所以 stdout 兜底是"假如 IPC 完全失败、子进程恰好把结果写到 stdout 才有用"——这是救命稻草,不是首选通道。

六、4 道防线的取舍与心智模型

把这套设计画到一张心智图上:

            失控脚本可能发生的"坏事情"
                       │
        ┌──────────────┼──────────────┐
        ▼              ▼              ▼
   进程逃逸         V8 隔离失守      运行时失控
   (require/process/ (读主进程       (死循环 / OOM /
   fetch)            全局变量)       异常逃逸)
        │              │              │
        ▼              ▼              ▼
   进程级隔离     V8 Context 隔离    SIGKILL 兜底
   child_process  vm.createContext  setTimeout →
   .fork()        + 白名单全局      child.kill
        │              │              │
        └──────────────┴──────────────┘
                       ▼
              父进程毫发无伤 + stderr 500 字异常

4 道防线的取舍:

  • 防线 1(进程):拦成本最高的"子进程崩溃拖垮主进程"。代价:fork + IPC 通信有 ~10-30ms 启动开销。
  • 防线 2(Context):拦"读到什么"。代价:每个 Context 有自己的 V8 实例内存,不能跨 Context 共享状态(必须显式序列化)。
  • 防线 3(编译期 timeout):拦"模块级死循环"。代价:5 秒是经验值,写复杂 import 链路时可能误杀。
  • 防线 4(SIGKILL):拦"运行时失控"。代价:超时即整批失败,无 partial success。

一个都不能少。砍掉任何一道,都会留下可被利用的边界。

七、双层沙箱的代价与收益

最后谈谈双层沙箱给对账系统带来的真实价值。这套设计在轻易云生产环境跑了一年以上,每天稳定支撑四类脚本的并行执行。

收益 1:业务人员自助编写脚本成为可能。 4 类沙箱脚本(parse / reconcile / allocate / transform)全部支持业务人员用 JS 直接编写,AI Agent 也能直接调用工具保存脚本。这意味着对账系统的边界扩展(新平台接入、新规则适配)不再依赖研发排期。

收益 2:脚本崩溃不影响对账主链路。 Parse 沙箱拒收、reconcile 沙箱超时、transform 沙箱异常——所有失败都由 JobTask 状态机接管,标记为 FAILED 并允许重跑,不会让一条坏脚本把整个 5 万行账单拖入"对账中"的死循环状态。

收益 3:审计可追溯。 stderr.slice(0, 500) 写进 JobTask.errorMessage,整条失败链可复盘:JobTask → 沙箱调用 → stderr 报错 → 用户脚本代码行 → 数据行号。这是企业级 SaaS 的合规底线。

代价 1:fork + IPC 启动开销 ~10-30ms/批。对于 10 万行大账单,需要 fork 1000 次——开销不可忽视。我们靠 BullMQ 队列并行 + Node.js 进程池覆盖,transform 单次预览反而最快(单次 fork + 30s 闸)。

代价 2:调试体验不如同进程 VM。 子进程崩溃后,IDE 调试器看不到变量栈。业务人员只能依赖沙箱内 query.log(...) + stderr 上报,开发体验打折。

代价 3:V8 Context 隔离 ≠ 内存隔离。vm.createContext 不设 memoryLimit,OOM 风险完全靠 child_process 兜底(即 OS 杀进程)。如果生产环境内存吃紧,可能出现"沙箱子进程被 OOM killer 干掉,父进程误以为是脚本异常"的误判。

沙箱脚本契约的具体细节(参数全兜底、export default 必须 await、试跑端点的 30 秒闸等),在 parse-script-development / reconciliation-script-development / integration-transform-script-development 三份专项 skill 文档里有更细的描述。

八、给安全工程师的 3 条实践建议

如果你的团队正在自研脚本沙箱,我们踩过的 3 个建议:

  1. 永远不要只做单层隔离——V8 隔离再强也拦不住死循环。哪怕只是 demo,也要 fork 子进程 + setTimeout → SIGKILL。
  2. stderr.slice(0, 500) 是排查关键——不要丢弃 stderr。哪怕只用前 500 字符,也能告诉你"是死循环 / OOM / 异常逃逸"中的哪一种。
  3. 超时参数分场景——同步预览 30 秒、后台批处理 60 秒、CI/CD 试跑 90 秒。没有"统一的"沙箱超时,只有"按业务 SLA 调"的超时。

九、收尾

回到文章开头那句基线:

V8 实例级隔离解决"脚本读到什么",进程级隔离解决"脚本失控时主进程还活着吗"。

双层沙箱不是技术炫技,是用两层独立的故障域换"主进程永远不崩"这条底线。在企业级对账系统里,主进程不崩意味着:哪怕业务人员写错脚本,最坏的结果是这一批 100 行账单标记 FAILED 等重跑,而不是整个对账链路瘫掉。

这也是为什么 v3 重构下决心删掉 1,800 行自研沙箱、换用 isolated-vm × child_process 双层成熟组件——简洁优先的双层,比花哨的五层更安全。

如果你正在评估对账系统的脚本能力,问厂商一句话:"你们的沙箱,SIGKILL 是哪一层做的?"——答不上来的,都是单层沙箱。

本文为原创内容,转载请注明出处:https://www.qeasy.cloud/insights/reconciliation/2-2-1-isolated-vm-child-process-dual-layer-sandbox

评论