Node:vm和vm2
node vm
作用:
最基本的作用是,为被调用的代码提供一个不同于调用代码的全局对象。这就避免了使用 eval() 时会出现的,能访问当前作用域的所有变量的情况。
同时,该模块支持控制代码块的编译、保存和运行。
方式:
使用 V8 Context 编译和运行代码。
代码块编译、保存和运行
1 | const vm = require('node:vm'); |
提供独立的全局对象
被调用的代码会将上下文中的所有属性当做全局变量处理;对于这里的全局变量的任何修改都会反映在上下文对象中(而不是原对象中)。
1 | const { createContext, runInContext } = require('node:vm'); |
要创建一个上下文,我们的做法是将一个对象“上下文化(contextify)”。在带有 object 参数来调用 vm.createContext() 方法时,这个对象将被上下文化;若没有带参数,当前的全局对象将被上下文化。就其实际流程而言,一个 V8 context 将被创建并关联于我们提供的 object。
值得注意的是,vm.createContext() 所返回的对象并不是内部的代码的 globalThis,可以理解为就是最开始传入的那个对象参数又吐出来还给我们。内部的代码的 globalThis 实际上是对 V8 的 context 的引用,可以看到它是无法 freeze 的。
1 | const { createContext, runInContext } = require('node:vm'); |
使用场景:Node.js require()
1 | // Node.js 内部 require 系统(极度简化) |
这样设计的好处是什么?相对于 ES6 Modules 的实现,虽然这种方式看起来略显 hack,但是它支持同步的动态加载(相对于 ES6 Modules 的 import() 之为异步,会导致所有调用该函数的位置被 async 污染);同时,还有一些相关的 hack 能力也会受限,这里不展开介绍了。
vm2
vm2 是一个可以使用 Node 的内建 vm 模块(也就是我们前文所介绍的 node vm)来运行不信任代码的库。相对于 vm,vm2 所做的改进实际就是使用 Proxy 来防御沙箱逃逸,包括包装所有穿越沙箱边界的对象,以及重载内建的 require 等。
要直观看到 vm2 与 vm 的区别,可以看下面的代码:
1 | import { runInNewContext } from "node:vm"; |
会出现这种区别,是因为在原本的 vm 中,内部通过 this.constructor 得到 Object,此时实际上就已经逃逸到宿主中去了。进一步地将可以向上追溯到 Function,从而最终取得了外层的 process 并退出了。而在 vm2 中,通过使用 Proxy,沙箱内的 this 已经被包装过,其 constructor 的 constructor 也已经是沙箱版本,访问不到 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 | // ===== isolated-vm: 复杂、异步、需要手动序列化一切 ===== |
同时,由于使用了一个全新的 V8 isolate,isolated-vm 中不能用 Node 模块,因为独立的 V8 实例意味着只有纯 JavaScript,没有任何 Node API,甚至连 console.log 也要自己注入。一个全新的 V8 isolate 相当于一个裸的浏览器 JS 引擎。
Deno:
使用 Deno 意味着使用一个完全不同的运行时,要把整个应用迁移过去,只能放弃 Node.js 生态。

