
vm2 v3.11.6
用于 Node.js 的隔离 JavaScript 沙箱,通过基于 Proxy 的拦截来限制对内置模块和主机资源的访问,从而运行不受信任的代码。
vm2 [![NPM Version][npm-image]][npm-url] [![NPM Downloads][downloads-image]][downloads-url] [![License][license-image]][license-url]
[![Known Vulnerabilities][snyk-image]][snyk-url]
vm2 是一个沙箱,可以在白名单中的 Node 内置模块下运行不受信任的代码。
重要安全声明
在使用 vm2 之前,你应该了解它的工作原理及其局限性。
vm2 尝试在与你的应用程序相同的 Node.js 进程内对不受信任的 JavaScript 代码进行沙箱隔离。它通过一个由 Proxies 组成的复杂网络来实现这一点,这些代理会拦截并调解沙箱与宿主环境之间的每一次交互。
根本性挑战
JavaScript 是一种极其动态的语言。对象可以通过原型链访问,构造函数可以通过错误对象获取,Symbol 提供了协议钩子,异步执行会创建时序窗口。在 JavaScript 中,从一个对象遍历到另一个对象的方式之多,使得构建一个严丝合缝的进程内沙箱变得极其困难。
我们对这一现实保持诚实: 尽管我们尽了最大努力,研究人员和安全专家仍不断发现逃逸 vm2 沙箱的新方法。我们会在这些漏洞被报告后积极修补,但进程内沙箱这种猫鼠游戏的性质意味着:
- 未来很可能会发现新的绕过方法。 请查看我们的 安全公告 了解已知漏洞。
- 你必须保持 vm2 更新,才能受益于最新的安全修复。订阅安全公告并及时更新。
- vm2 不应成为你唯一的防线。 在运行不受信任的代码时,纵深防御至关重要。
更稳健的替代方案
如果你需要更强的隔离保证,请考虑这些提供真正的进程级或硬件级隔离的替代方案:
| 解决方案 | 方法 | 性能 | 权衡 |
|---|---|---|---|
| isolated-vm | 独立的 V8 isolate(不同的 V8 堆) | 快 | 处于维护模式;需要手动更新 V8 |
| 独立进程 / Worker | 权限受限的 child_process 或 Worker 线程 | 中等 | IPC 开销较高;数据必须序列化 |
| 容器 / 虚拟机 | Docker、gVisor、Firecracker | 慢 | 启动开销;资源密集 |
| 托管服务 | 基于云的代码执行(例如 AWS Lambda、Cloudflare Workers) | 可变 | 网络延迟;外部依赖 |
何时 vm2 可能仍然适用
vm2 在以下情况下可能适用:
- 你需要与宿主对象紧密集成,并进行快速的同步通信
- 不受信任的代码来自相对可信的来源(例如内部工具、作者经过审核的插件系统)
- 你将 vm2 与其他安全层结合使用(网络隔离、文件系统限制、资源限制)
- 你接受风险并积极关注安全更新
如果你运行的代码来自完全不受信任的来源(例如任意用户提交),我们强烈建议使用隔离保证更强的解决方案。
功能
- 在与你的代码并行的单进程中安全地运行不受信任的代码
- 完全控制沙箱的控制台输出
- 沙箱对进程方法的访问受限
- 可以在沙箱中 require 模块(内置和外部)
- 你可以限制对某些(或全部)内置模块的访问
- 你可以在沙箱之间安全地调用方法、交换数据和回调
- 积极维护并针对已知逃逸方法提供补丁(参见 安全声明)
- 支持转译器
它是如何工作的
- 它使用内部的 VM 模块创建一个安全上下文。
- 它使用 Proxies 来防止逃逸沙箱。
- 它覆盖内置的 require 以控制对模块的访问。
如需深入了解 vm2 的内部机制,请参阅 CONTRIBUTING.md 文件。
Node 的 vm 与 vm2 有什么区别?
你自己试试:```js import { runInNewContext } from "node:vm";
runInNewContext('this.constructor.constructor("return process")().exit()'); console.log('Never gets executed.');
输入内容为空,未收到需要翻译的文本。请提供 chunk 3 的实际内容后,我将按要求完成翻译。```js
import { VM } from 'vm2';
new VM().run('this.constructor.constructor("return process")().exit()');
// Throws ReferenceError: process is not defined
安装```sh
npm install vm2
## 快速示例```js
import { VM } from 'vm2';
const vm = new VM();
vm.run(`process.exit()`); // TypeError: process.exit is not a function
Anthony 撰写的关于 Ansible Vault 的精彩博客文章
哎呀,运行此命令时你需要修复这个命令,因为我打错了,抱歉:
vault.ansible.md
``````js
import { NodeVM } from 'vm2';
const vm = new NodeVM({
require: {
external: true,
root: './',
},
});
vm.run(
`
var request = require('request');
request('http://www.google.com', function (error, response, body) {
console.error(error);
if (!error && response.statusCode == 200) {
console.log(body); // Show the HTML for the Google homepage.
}
});
`,
'vm.js',
);
Documentation
VM
VM 是一个简单的沙箱,用于在没有 require 功能的情况下同步运行不受信任的代码。只提供 JavaScript 内置对象和 Node 的 Buffer。调度函数(setInterval、setTimeout 和 setImmediate)默认不可用。
选项:
timeout- 脚本超时时间(毫秒)。警告:您可能希望将此选项与allowAsync=false一起使用。此外,对沙箱返回的对象进行操作可能会执行任意代码并绕过超时。在另一种情况下,应使用typeof测试返回的对象是否为原始类型,并完全丢弃它(使用此类对象进行日志记录或创建错误消息也可能再次执行任意代码)。sandbox- VM 的全局对象。compiler-javascript(默认)、typescript、coffeescript或自定义编译器函数。如果将值设置为typescript或coffeescript,库要求您预先安装相应的编译器。eval- 如果设置为false,任何对eval或函数构造函数(Function、GeneratorFunction等)的调用都将抛出EvalError(默认值:true)。wasm- 如果设置为false,任何编译 WebAssembly 模块的尝试都将抛出WebAssembly.CompileError(默认值:true)。注意:出于安全原因,WebAssembly.JSTag已在沙箱内移除,因此 wasm 代码无法捕获 JavaScript 异常。allowAsync- 如果设置为false,任何使用async运行代码的尝试都将抛出VMError(默认值:true)。bufferAllocLimit- 从沙箱内部发出的单个Buffer.alloc/Buffer.allocUnsafe/Buffer.allocUnsafeSlow/Buffer(N)/new Buffer(N)请求的最大字节数。超过此上限的请求会同步抛出RangeError,而不执行宿主分配。默认值:Infinity(无上限,完全向后兼容)。在内存受限环境(Docker / Kubernetes / Lambda / serverless)中运行不受信任代码的嵌入者应选择有限上限(例如32 * 1024 * 1024),作为分层 DoS 防御的一部分,就像他们选择timeout一样。请参阅下面的加固建议。
重要:超时仅对您通过 run 运行的同步代码有效。超时不能作用于 VM 返回的任何方法。有些情况下超时不起作用 - 请参阅 #244。```js
import { VM } from 'vm2';
const vm = new VM({ timeout: 1000, allowAsync: false, sandbox: {}, });
vm.run('process.exit()'); // throws ReferenceError: process is not defined
你也可以从 VM 中获取值。```js
let number = vm.run('1337'); // returns 1337
提示:有关更多用法示例,请参阅测试。
NodeVM
与 VM 不同,NodeVM 允许你以在常规 Node 上下文中相同的方式 require 模块。
选项:
console-inherit启用控制台,redirect重定向到事件,off禁用控制台(默认:inherit)。sandbox- VM 的全局对象。compiler-javascript(默认)、typescript、coffeescript或自定义编译器函数(该函数接收代码及其文件路径)。如果值设置为typescript或coffeescript,库期望你已预装相应的编译器。eval- 如果设置为false,任何对eval或函数构造器(Function、GeneratorFunction等)的调用都将抛出EvalError(默认:true)。wasm- 如果设置为false,任何编译 WebAssembly 模块的尝试都会抛出WebAssembly.CompileError(默认:true)。注意:出于安全原因,沙箱内部会移除WebAssembly.JSTag,因此 wasm 代码无法捕获 JavaScript 异常。bufferAllocLimit- 与VM上的语义相同——沙箱内部发出的单个Buffer.alloc系列请求的最大字节数。默认:Infinity。请参阅 加固建议。sourceExtensions- 要视为源代码的文件扩展名数组(默认:['js'])。require- 设为true、一个对象或一个 Resolver 以启用require方法(默认:false)。require.external- 值可以是true、允许的外部模块数组或一个对象(默认:false)。所有匹配/node_modules/${any_allowed_external_module}/(?!/node_modules/)的路径都允许被 require。require.external.modules- 允许的外部模块数组。同样支持通配符,例如指定['@scope/*-ver-??]将允许使用所有名称形式为@scope/something-ver-aa、@scope/other-ver-11等的模块。*通配符不匹配路径分隔符。require.external.transitive- 布尔值,指示是否允许外部模块的传递依赖(默认:false)。警告:当某个模块被间接 require 后,任何模块随后都能正常 require 它,即使在此之前这是不可能的。require.builtin- 允许的内置模块数组,接受 ["*"] 表示全部(默认:无)。警告:"*" 可能很危险,因为会添加新的内置模块。require.root- 允许 require 本地模块的受限路径(默认:所有路径)。require.mock- 模拟模块集合(包括外部或内置模块)。require.context-host(默认)在主机中 require 模块并将它们代理到沙箱中。sandbox在沙箱中加载、编译和 require 模块。callback(moduleFilename, ext)为每个模块动态选择上下文。如果未指定任何内容,默认将是 sandbox。除events外,内置模块总是在主机中 require 并代理到沙箱中。require.import- 启动时要加载到 NodeVM 中的模块数组。require.resolve- 当模块在传统的 node 查找路径中未找到时使用的附加查找函数。require.customRequire- 用于替代require函数从主机加载模块。require.strict- 设置为false以不强制 require 加载的模块使用严格模式(默认:true)。require.fs- 自定义文件系统实现。nesting- 警告:允许此选项存在安全风险,因为脚本可以创建 NodeVM,进而 require 任何主机模块。设为true以启用 VM 嵌套(默认:false)。wrapper-commonjs(默认)将脚本包装到 CommonJS 包装器中,none以检索脚本返回的值。argv- 传递给process.argv的数组。env- 传递给process.env的对象。strict- 设为true使加载的模块处于严格模式(默认:false)。
重要提示:超时对 NodeVM 无效,因此它无法抵御 while (true) {} 或类似的恶意代码。
请记住:你允许的模块越多,你的沙箱就越脆弱。```js import { NodeVM } from 'vm2';
const vm = new NodeVM({ console: 'inherit', sandbox: {}, require: { external: true, builtin: ['fs', 'path'], root: './', mock: { fs: { readFileSync: () => 'Nice try!', }, }, }, });
// Sync
let functionInSandbox = vm.run('module.exports = function(who) { console.log("hello "+ who); }'); functionInSandbox('world');
// Async
let functionWithCallbackInSandbox = vm.run('module.exports = function(who, callback) { callback("hello "+ who); }'); functionWithCallbackInSandbox('world', greeting => { console.log(greeting); });
当 `wrapper` 设置为 `none` 时,对于同步代码,`NodeVM` 的行为更类似于 `VM`。```js
assert.ok(vm.run('return true') === true);
提示:有关更多用法示例,请参阅测试。
通过相对路径加载模块
要通过相对路径加载模块,如果脚本是字符串,则必须将正在运行的脚本的完整路径作为第二个参数传递给 vm 的 run 方法。随后,该文件名将显示在脚本生成的任何堆栈跟踪中。```js
vm.run('require("foobar")', '/data/myvmscript.js');
如果您正在运行的脚本是 VMScript,则该路径在 VMScript 构造函数中给出。```js
const script = new VMScript('require("foobar")', { filename: '/data/myvmscript.js' });
vm.run(script);
Resolver
可以通过 makeResolverFromLegacyOptions 创建 resolver,并可将其用于多个 NodeVM 实例,从而共享已编译的模块代码,可能加快加载速度。第一个 NodeVM 示例可以使用 makeResolverFromLegacyOptions 重写,如下所示。```js
const resolver = makeResolverFromLegacyOptions({
external: true,
builtin: ['fs', 'path'],
root: './',
mock: {
fs: {
readFileSync: () => 'Nice try!',
},
},
});
const vm = new NodeVM({
console: 'inherit',
sandbox: {},
require: resolver,
});
## VMScript
你可以通过使用预编译脚本来提高性能。预编译的 VMScript 可以多次运行。需要注意的是,代码并不绑定到任何 VM(上下文);而是在每次运行前才绑定,且仅针对该次运行。```js
import { VM, VMScript } from 'vm2';
const vm = new VM();
const script = new VMScript('Math.random()');
console.log(vm.run(script));
console.log(vm.run(script));
它同时适用于 VM 和 NodeVM。```js
import { NodeVM, VMScript } from 'vm2';
const vm = new NodeVM(); const script = new VMScript('module.exports = Math.random()'); console.log(vm.run(script)); console.log(vm.run(script));
代码在首次运行时自动编译。可以随时使用 `script.compile()` 编译代码。代码编译后,该方法将不再生效。
## 错误处理
代码编译和同步代码执行中的错误可以通过 `try-catch` 处理。异步代码执行中的错误可以通过为 Node 的 `process` 附加 `uncaughtException` 事件处理器来处理。```js
try {
var script = new VMScript('Math.random()').compile();
} catch (err) {
console.error('Failed to compile script.', err);
}
try {
vm.run(script);
} catch (err) {
console.error('Failed to execute script.', err);
}
process.on('uncaughtException', err => {
console.error('Asynchronous error caught.', err);
});
调试沙箱中的代码
您可以像调试普通进程一样,调试或检查在沙箱中运行的代码。
- 您可以使用断点(这要求您指定脚本文件名)
- 您可以使用
debugger关键字。 - 您可以使用单步进入(step-in)来进入沙箱中运行的代码。
示例
/tmp/main.js:```js import { VM, VMScript } from 'vm2'; import { readFileSync } from 'node:fs';
const file = ${__dirname}/sandbox.js;
// By providing a file name as second argument you enable breakpoints const script = new VMScript(readFileSync(file), file);
new VM().run(script);
/tmp/sandbox.js```js
const foo = 'ahoj';
// The debugger keyword works just fine everywhere.
// Even without specifying a file name to the VMScript object.
debugger;
只读对象(实验性)
为了防止沙箱脚本向代理对象添加、更改或删除属性,你可以使用 freeze 方法使对象变为只读。这仅在 VM 内部有效。冻结对象会被深度影响。原始类型无法被冻结。
未使用 freeze 的示例:```js
const util = {
add: (a, b) => a + b,
};
const vm = new VM({ sandbox: { util }, });
vm.run('util.add = (a, b) => a - b'); console.log(util.add(1, 1)); // returns 0
**使用 `freeze` 的示例:**```js
const vm = new VM(); // Objects specified in the sandbox cannot be frozen.
vm.freeze(util, 'util'); // Second argument adds object to global.
vm.run('util.add = (a, b) => a - b'); // Fails silently when not in strict mode.
console.log(util.add(1, 1)); // returns 2
重要说明: 无法冻结已代理到 VM 的对象。
受保护对象(实验性)
与 freeze 不同,此方法允许沙箱脚本对对象添加、更改或删除属性,但有一个例外——无法附加函数。因此,沙箱脚本无法修改诸如 toJSON、toString 或 inspect 之类的方法。
重要说明: 无法保护已代理到 VM 的对象。
跨沙箱关系```js
const assert = require('assert'); const { VM } = require('vm2');
const sandbox = { object: new Object(), func: new Function(), buffer: new Buffer([0x01, 0x05]), };
const vm = new VM({ sandbox });
assert.ok(vm.run(object) === sandbox.object);
assert.ok(vm.run(object instanceof Object));
assert.ok(vm.run(object) instanceof Object);
assert.ok(vm.run(object.__proto__ === Object.prototype));
assert.ok(vm.run(object).proto === Object.prototype);
assert.ok(vm.run(func) === sandbox.func);
assert.ok(vm.run(func instanceof Function));
assert.ok(vm.run(func) instanceof Function);
assert.ok(vm.run(func.__proto__ === Function.prototype));
assert.ok(vm.run(func).proto === Function.prototype);
assert.ok(vm.run(new func() instanceof func));
assert.ok(vm.run(new func()) instanceof sandbox.func);
assert.ok(vm.run(new func().__proto__ === func.prototype));
assert.ok(vm.run(new func()).proto === sandbox.func.prototype);
assert.ok(vm.run(buffer) === sandbox.buffer);
assert.ok(vm.run(buffer instanceof Buffer));
assert.ok(vm.run(buffer) instanceof Buffer);
assert.ok(vm.run(buffer.__proto__ === Buffer.prototype));
assert.ok(vm.run(buffer).proto === Buffer.prototype);
assert.ok(vm.run(buffer.slice(0, 1) instanceof Buffer));
assert.ok(vm.run(buffer.slice(0, 1)) instanceof Buffer);
## CLI
在命令行中使用 vm2 之前,请先通过 `npm install vm2 -g` 全局安装它。```sh
vm2 ./script.js
加固建议
vm2 可防止沙箱逃逸(不可信代码获取宿主 realm 访问权限)。但它本身并不能防止所有形式的资源耗尽或拒绝服务。运行不可信代码的嵌入方应在沙箱周围添加以下分层防御。
1. 使用 bufferAllocLimit 限制内存分配
单个 Buffer.alloc(N) 调用(其中 N 由攻击者控制)会以同步宿主 C++ 分配的形式运行,V8 的 timeout 无法中断。在内存受限的环境中,约 100 字节的沙箱载荷即可导致宿主 RSS 跃升 100 MB 以上,并因 OOM 导致宿主进程崩溃。设置 bufferAllocLimit(例如 32 * 1024 * 1024)以限制单次分配:
const vm = new VM({
timeout: 1000,
bufferAllocLimit: 32 * 1024 * 1024,
allowAsync: false,
});
```
该上限同样适用于已弃用的 `Buffer(N)` 和 `new Buffer(N)` 路径。请注意,聚合性耗尽(大量小型分配、`Buffer.concat`、`Uint8Array`、`String.repeat`、`Array(n).fill()` 等)**不**受此上限覆盖——请配合宿主侧内存限制(`--max-old-space-size`、容器限制、cgroup)以实现全面覆盖。
### 2. 安装宿主侧的 `unhandledRejection` 处理器
存在一类宿主进程中止型 DoS:沙箱代码创建 `async function`、`async function*` 或 `await using`,其函数体抛出的值在栈格式化期间触发宿主环境错误(例如 `e.name = Symbol(); e.stack`)。V8 通过宿主环境的原生 Promise 创建 rejection promise,绕过了 vm2 的 `Promise` 子类封装,因此该 rejection 会以 `unhandledRejection` 形式逃逸到宿主。在 Node 15+ 上,默认行为是终止进程。
要堵住此漏洞,需要改变可观察的宿主行为,因此 vm2 默认不提供修复。嵌入方应安装一个进程级处理器,用于吞掉(或记录)源自沙箱的 rejection:```js
// Recommended: filter rejections that originated inside vm2 and swallow them,
// while letting your own host-side rejections propagate.
process.on('unhandledRejection', (reason, promise) => {
// Heuristic: rejections from the sandbox frequently surface as values
// without proper Error semantics, or with stacks pointing at vm.js.
// Adjust the predicate to match your application.
if (looksLikeSandboxOrigin(reason)) {
return; // swallow — don't terminate the process
}
// Otherwise: handle (or rethrow) as normal for your host code.
yourLogger.error('unhandled rejection', reason);
});
```
如果你的应用没有其他未处理拒绝的来源,那么全面吞掉并记录日志是可以接受的:```js
process.on('unhandledRejection', reason => {
yourLogger.warn('swallowed sandbox rejection', reason);
});
```
针对性修复可能在未来某个次要版本中随一个可选择开启的 `swallowSandboxUnhandledRejections` 标志一同发布;在此之前,宿主端的处理器是推荐的缓解措施。
### 3. 以进程级内存上限运行
即使设置了 `bufferAllocLimit`,也应使用与工作负载匹配的 `--max-old-space-size`(或等效的容器内存限制)来运行宿主进程。该上限可防范单次分配原语;操作系统级限制可防范总体耗尽,以及 vm2 尚未限制的任何未来分配原语。
### 4. 将 `require.builtin: ['*']` 视为非沙箱配置
`'*'` 通配符会扩展为大多数 Node 内置模块,包括 `child_process`、`fs`、`dgram`、`net`、`http` 和 `dns`。这些是完整的宿主能力原语——在 `'*'` 下,从沙箱中可以访问 `require('child_process').execSync('id')`。vm2 的 `'*'` 语义是有意为之的(一些嵌入者运行的是可信但隔离的代码),但它不应被用作不受信任代码的默认配置。建议使用显式允许列表,仅包含你的沙箱实际所需的最小模块集合。
### 5. `nesting: true` 是一个逃生舱口
`nesting: true` 允许沙箱代码 `require('vm2')` 并构造嵌套的 NodeVM。**嵌套 VM 的 `require` 配置由构造它的沙箱代码选择,不受外部 VM 约束。**具体来说:```js
const vm = new NodeVM({ nesting: true, require: { builtin: [] } });
vm.run(`
const { NodeVM: NVM } = require('vm2');
// Inner VM's config is whatever the sandbox writes here:
const inner = new NVM({ require: { builtin: ['child_process'] } });
inner.run('require("child_process").execSync("id")'); // RCE
`);
```
如果你设置了 `nesting: true`,实际上就等于授予了沙箱与你相同级别的信任。**不要对不受信任的代码启用 `nesting: true`。** 只有在你自己信任沙箱代码本身,但出于非安全原因想要获得 VM 式执行语义(全新的全局对象、受控超时)时才使用它。
`nesting: true` **需要一个显式的 `require` 配置对象**(例如 `require: { builtin: [] }` 或 `require: {}`)。任何其他形式——`require: false`、`require: undefined`、`require: null` 或完全省略 `require`——在构造时都会抛出 `VMError`(GHSA-m4wx-m65x-ghrr,取代了 GHSA-8hg8-63c5-gwmx)。所有这些形式都会产生一个仅支持 NESTING_OVERRIDE 的解析器:沙箱只能 `require('vm2')`,无法 require 其他任何内容,这纯粹是一个没有合法用途的逃逸原语。若要拒绝所有 require,请移除 `nesting: true`。若要允许嵌套 VM,请提供显式的 `require` 配置,以便在调用点就能看到这种权衡取舍。
## 已知问题
- 无法定义继承自被代理类的类。这包括在 `Object.create` 中使用被代理类。
- 直接 eval 无法工作。
- 对沙箱数组执行日志记录时,其属性中会重复显示数组部分。
- 源代码转换可能导致函数返回不同的源字符串。
- 存在从沙箱内部使 node 进程崩溃的方法。请参阅[加固建议](#hardening-recommendations)。
[npm-image]: https://img.shields.io/npm/v/vm2.svg
[npm-url]: https://www.npmjs.com/package/vm2
[license-image]: https://img.shields.io/npm/l/vm2.svg
[license-url]: https://raw.githubusercontent.com/patriksimek/vm2/resurrection/LICENSE.md
[downloads-image]: https://img.shields.io/npm/dm/vm2.svg
[downloads-url]: https://www.npmjs.com/package/vm2
[snyk-image]: https://snyk.io/test/github/patriksimek/vm2/badge.svg
[snyk-url]: https://snyk.io/test/github/patriksimek/vm2