node vm

作用:
最基本的作用是,为被调用的代码提供一个不同于调用代码的全局对象。这就避免了使用 eval() 时会出现的,能访问当前作用域的所有变量的情况。
同时,该模块支持控制代码块的编译、保存和运行。
方式:
使用 V8 Context 编译和运行代码。

代码块编译、保存和运行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const vm = require('node:vm');

// ====== 方式一:立即编译并执行 ======
vm.runInNewContext('x + 1', { x: 2 }); // 返回 3

// ====== 方式二:先编译,稍后再执行 ======
// 第一步:编译(此时不执行)
const script = new vm.Script('x + 1');

// 可以过很久……

// 第二步:想什么时候执行就什么时候执行,还能在不同 context 里反复执行
const ctx1 = vm.createContext({ x: 10 });
const ctx2 = vm.createContext({ x: 20 });

script.runInContext(ctx1); // 11
script.runInContext(ctx2); // 21

提供独立的全局对象

被调用的代码会将上下文中的所有属性当做全局变量处理;对于这里的全局变量的任何修改都会反映在上下文对象中(而不是原对象中)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const { createContext, runInContext } = require('node:vm');

const x = 1;

const context = { x: 2 };
createContext(context); // Contextify the object.

const code = 'x += 40; var y = 17;';
// `x` and `y` are global variables in the context.
// Initially, x has the value 2 because that is the value of context.x.
runInContext(code, context);

console.log(context.x); // 42
console.log(context.y); // 17

console.log(x); // 1; y is not defined

要创建一个上下文,我们的做法是将一个对象“上下文化(contextify)”。在带有 object 参数来调用 vm.createContext() 方法时,这个对象将被上下文化;若没有带参数,当前的全局对象将被上下文化。就其实际流程而言,一个 V8 context 将被创建并关联于我们提供的 object。
值得注意的是,vm.createContext() 所返回的对象并不是内部的代码的 globalThis,可以理解为就是最开始传入的那个对象参数又吐出来还给我们。内部的代码的 globalThis 实际上是对 V8 的 context 的引用,可以看到它是无法 freeze 的。

1
2
3
4
5
6
7
8
9
10
11
12
13
const { createContext, runInContext } = require('node:vm');

// An undefined `contextObject` option makes the global object contextified.
const context = createContext();
console.log(runInContext('globalThis', context) === context); // false
// A contextified global object cannot be frozen.
try {
runInContext('Object.freeze(globalThis);', context);
} catch (e) {
console.log(`${e.constructor.name}: ${e.message}`);
// TypeError: Cannot freeze
}
console.log(runInContext('globalThis.foo = 1; foo;', context)); // 1

使用场景:Node.js require()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Node.js 内部 require 系统(极度简化)

function loadModule(filepath) {
// 1. 读文件
const code = fs.readFileSync(filepath, 'utf8');

// 2. 包成函数
const wrapped = '(function(exports, require, module, __filename, __dirname){' + code + '})';
// () 用于声明这是一个表达式,返回值是函数本身
const fn = vm.Script(wrapped).runInThisContext();

// 3. 准备 module 对象
const module = { exports: {} };

// 4. 给这个模块造一个专属的 require
function childRequire(path) {
// 函数内部就是再次调用 loadModule
const fullPath = resolve(filepath, path); // 根据当前文件位置解析路径
return loadModule(fullPath); // 递归调用
}

// 5. 传入
fn(module.exports, childRequire, module, filepath, dirname(filepath));
// ↑
// 传的是 childRequire,不是 loadModule 本身
// 但 childRequire 内部会调用 loadModule

return module.exports;
}

这样设计的好处是什么?相对于 ES6 Modules 的实现,虽然这种方式看起来略显 hack,但是它支持同步的动态加载(相对于 ES6 Modules 的 import() 之为异步,会导致所有调用该函数的位置被 async 污染);同时,还有一些相关的 hack 能力也会受限,这里不展开介绍了。

vm2

vm2 是一个可以使用 Node 的内建 vm 模块(也就是我们前文所介绍的 node vm)来运行不信任代码的库。相对于 vmvm2 所做的改进实际就是使用 Proxy 来防御沙箱逃逸,包括包装所有穿越沙箱边界的对象,以及重载内建的 require 等。

要直观看到 vm2vm 的区别,可以看下面的代码:

1
2
3
4
5
6
7
8
import { runInNewContext } from "node:vm";

runInNewContext('this.constructor.constructor("return process")().exit()');
console.log('Never gets executed.'); // 外部进程已经被exit掉了
import { VM } from 'vm2';

new VM().run('this.constructor.constructor("return process")().exit()');
// Throws ReferenceError: process is not defined

会出现这种区别,是因为在原本的 vm 中,内部通过 this.constructor 得到 Object,此时实际上就已经逃逸到宿主中去了。进一步地将可以向上追溯到 Function,从而最终取得了外层的 process 并退出了。而在 vm2 中,通过使用 Proxy,沙箱内的 this 已经被包装过,其 constructorconstructor 也已经是沙箱版本,访问不到 process

那么什么是 Proxy?

Proxy 对象允许你创建一个原始对象的替代对象,并在这个对象上重定义基本的对象操作如 get,set 以及属性定义。
你可使用以下两个参数创建一个 Proxy

  • target:想要代理的原对象
  • handler:定义了要监听什么操作以及如何重定义监听的操作的对象

为何选择 vm2

尽管如此,受限于 vm 的实现,vm2 依旧是一个不安全的实现,它的安全性全部取决于 Proxy 有没有包裹住所有的交互。

vm2 适用于以下情况:

  • 宿主和沙箱需要紧密整合并且需要低时延通信(同步通信)
  • 不信任代码来源于一个相对可信的来源
  • 结合了别的安全限制手段(e.g. 网络隔离、文件系统隔离、资源限制)
  • (使用 vm2 的开发者也就是你)更新很勤快

这些反过来说就是 vm2 的优点:

  • 在同一进程中安全运行不可信代码,与原代码并排执行
  • 可完全控制沙箱的控制台输出
  • 能够从沙箱 require 模块(不论是内建的还是外部的)
  • 可限制对特定内置模块的访问
  • 可在不同沙盒之间安全地调用方法、交换数据及回调函数

竞品分析

下面考虑两个 vm2 的经典竞品。
isolated-vm:

  • 不能直接传对象,必须手动序列化 ivm.ExternalCopy() 然后 jail.set()
  • 不能直接传函数,必须用 ivm.Reference() 然后 jail.set()
  • 沙箱里面还是要手动序列化/反序列化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// ===== isolated-vm: 复杂、异步、需要手动序列化一切 =====
const ivm = require('isolated-vm');

const isolate = new ivm.Isolate({ memoryLimit: 128 });
const context = await isolate.createContext();
const jail = context.global;

// 不能直接传对象!必须手动序列化
await jail.set('usersJson', new ivm.ExternalCopy(
JSON.stringify([{ name: 'Alice', age: 20 }, { name: 'Bob', age: 15 }])
).copyInto());

// 不能直接传函数!必须用 Reference
await jail.set('save', new ivm.Reference(function(dataJson) {
const data = JSON.parse(dataJson);
db.insert(data);
}));

// 沙箱内也要手动序列化/反序列化
const script = await isolate.compileScript(`
const users = JSON.parse(usersJson);
const filtered = users.filter(u => u.age >= 18);
save.applySync(undefined, [JSON.stringify(filtered)]);
`);
await script.run(context);
// ↑ 全程异步,数据靠 JSON 搬运

同时,由于使用了一个全新的 V8 isolate,isolated-vm 中不能用 Node 模块,因为独立的 V8 实例意味着只有纯 JavaScript,没有任何 Node API,甚至连 console.log 也要自己注入。一个全新的 V8 isolate 相当于一个裸的浏览器 JS 引擎。

Deno:
使用 Deno 意味着使用一个完全不同的运行时,要把整个应用迁移过去,只能放弃 Node.js 生态。