Skip to content

Commit 46cbbdd

Browse files
patriksimekclaude
andcommitted
fix(GHSA-8hg8-63c5-gwmx): reject contradictory { nesting: true, require: false } at construction
The pair { nesting: true, require: false } silently produced an unsandboxed NodeVM. nesting: true injects a NESTING_OVERRIDE builtin that exposes vm2 to the sandbox regardless of require: false. Sandbox code then constructs an inner NodeVM with attacker-chosen require config (which is unconstrained by the outer config — by design of nesting) and reaches child_process for full host RCE. Reporter framed this precisely: 'mental-model mismatch'. A developer who sets require: false to lock down modules then enables nesting: true for legitimate child-VM use believes the sandbox is restricted. It is not. Fix: NodeVM constructor throws VMError immediately when both options are set explicitly. Same shape as the GHSA-cp6g eager FileSystem-contract probe — surface contradictory configuration at construction with a clear, actionable error rather than silently producing an unsandboxed sandbox. Bare new NodeVM({ nesting: true }) (no require config) continues to work as the documented escape hatch — out of scope for this patch. The deeper issue (nesting: true is fundamentally an escape hatch and grants sandbox unrestricted host access via inner NodeVMs) is now documented prominently: - README new section '5. nesting: true is an escape hatch' under Hardening recommendations. Spells out the inner-VM independence and concludes 'do not enable nesting: true for untrusted code'. - JSDoc on the nesting option upgraded to match. - ATTACKS.md Category 25 documents the configuration trap and the matching row in the 'How The Bridge Defends' table. 6 tests in test/ghsa/GHSA-8hg8-63c5-gwmx/repro.js cover: rejection of the contradictory pair, blocking of the original PoC, regression guards for { nesting: true, require: { builtin: [] } }, bare nesting: true, require: false alone, and default config. What this fix does NOT close: nesting: true with any non-false require config remains an escape hatch. Constraint propagation from outer to inner NodeVM was considered and deferred — would change documented nesting semantics substantially, major-version-shaped change. Bump to 3.11.1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fc0da54 commit 46cbbdd

6 files changed

Lines changed: 231 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,28 @@
11
# Changelog
22

3+
## [3.11.1]
4+
5+
Single advisory closed plus prominent documentation of an existing escape hatch. Patch release — no API changes for valid configurations.
6+
7+
### Security fix
8+
9+
- **GHSA-8hg8-63c5-gwmx**`nesting: true` bypassed `require: false`, allowing sandbox-to-host RCE via inner NodeVM construction. The contradictory option pair `{ nesting: true, require: false }` now throws `VMError` at `new NodeVM(...)` time citing the advisory. Same shape as the GHSA-cp6g eager FileSystem-contract probe — surface contradictory configuration at the API surface, not silently produce an unsandboxed sandbox. ATTACKS.md Category 25.
10+
11+
### Documentation
12+
13+
- New README section **"`nesting: true` is an escape hatch"** under Hardening recommendations. Explains that `nesting: true` lets sandbox code `require('vm2')` and construct nested NodeVMs whose `require` config is chosen by the sandbox (not constrained by the outer config — by design of nesting). **Do not enable `nesting: true` for untrusted code.**
14+
- JSDoc on the `nesting` option (`lib/nodevm.js`) upgraded to spell out the escape-hatch semantics and the GHSA-8hg8 contradictory-pair rejection.
15+
- ATTACKS.md gains Category 25 documenting the configuration trap and a matching row in the "How The Bridge Defends" table.
16+
17+
### Upgrade notes
18+
19+
- **If you set `{ nesting: true, require: false }`** anywhere in your codebase, `new NodeVM(...)` now throws. Either drop `nesting: true` (if you wanted deny-all), or replace `require: false` with an explicit `require` config (e.g. `require: { builtin: [] }`) to acknowledge that vm2 will be requireable. The error message is actionable and links to the README section.
20+
- **No other configurations are affected.** Bare `new NodeVM({ nesting: true })` continues to work as documented; this is the documented escape hatch and is not closed by this patch (out of scope — would change `nesting: true` semantics substantially).
21+
22+
### What this fix does NOT close
23+
24+
`nesting: true` itself remains an escape hatch for any non-trivial `require` config. The fix closes the **specific contradictory pair** flagged by the advisory; the broader recommendation is in the new README section: do not enable `nesting: true` when running untrusted code. Constraint propagation from outer to inner NodeVM (where the outer's `require` config would constrain inner construction) was considered and deferred — it would change the documented semantics of `nesting: true` and is a major-version-shaped change.
25+
326
## [3.11.0]
427

528
Coordinated security release closing 13 advisories, plus a new `bufferAllocLimit` option and a `realpath()` method on the FileSystem adapter contract. Minor version bump because of the new public option and the FileSystem contract addition; no incompatible changes to the existing public API surface. Embedders running untrusted code in memory-constrained environments should review the new `bufferAllocLimit` option and the README's [Hardening recommendations](README.md#hardening-recommendations) section.

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,24 @@ Even with `bufferAllocLimit` set, run the host process with `--max-old-space-siz
502502

503503
The `'*'` wildcard expands to most Node built-ins, including `child_process`, `fs`, `dgram`, `net`, `http`, and `dns`. These are full host-capability primitives — `require('child_process').execSync('id')` is reachable from the sandbox under `'*'`. vm2's `'*'` semantics are intentional (some embedders run trusted-but-isolated code), but it should not be used as a default for untrusted code. Prefer an explicit allowlist of the smallest set of modules your sandbox actually needs.
504504

505+
### 5. `nesting: true` is an escape hatch
506+
507+
`nesting: true` lets sandbox code `require('vm2')` and construct nested NodeVMs. **The nested VM's `require` config is chosen by the sandbox code that constructs it, not constrained by the outer VM.** Concretely:
508+
509+
```js
510+
const vm = new NodeVM({ nesting: true, require: { builtin: [] } });
511+
vm.run(`
512+
const { NodeVM: NVM } = require('vm2');
513+
// Inner VM's config is whatever the sandbox writes here:
514+
const inner = new NVM({ require: { builtin: ['child_process'] } });
515+
inner.run('require("child_process").execSync("id")'); // RCE
516+
`);
517+
```
518+
519+
If you set `nesting: true`, you have effectively granted the sandbox the same trust level you have. **Do not enable `nesting: true` for untrusted code.** Use it only when you trust the sandboxed code itself but want VM-style execution semantics (fresh global, controlled timeouts) for non-security reasons.
520+
521+
The combination `{ nesting: true, require: false }` throws `VMError` at construction (GHSA-8hg8-63c5-gwmx) because the pair is contradictory: `nesting: true` makes `vm2` requireable regardless of `require: false`, so the deny-all expectation cannot be honored. To deny all requires, remove `nesting: true`. To allow nested VMs, replace `require: false` with an explicit config so the tradeoff is visible.
522+
505523
## Known Issues
506524

507525
- It is not possible to define a class that extends a proxied class. This includes using a proxied class in `Object.create`.

docs/ATTACKS.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1798,6 +1798,78 @@ The race window between the canonicalization syscall and the subsequent loader s
17981798
17991799
---
18001800
1801+
## Attack Category 25: NodeVM `nesting: true` + `require: false` Configuration Trap
1802+
1803+
### Description
1804+
1805+
`NodeVM`'s `nesting: true` option injects a `NESTING_OVERRIDE` builtin that exposes the `vm2` package to sandbox code regardless of any other `require` configuration. The override is unconditional — it survives `require: false`, narrow `builtin` allowlists, and every other restriction the user might set. With `vm2` reachable, the sandbox constructs an inner `NodeVM` whose `require` config is **chosen by the sandbox code, not constrained by the outer config** (this is by design of `nesting`). The inner NodeVM can be configured with `child_process`, `fs`, or any other host module → full host RCE.
1806+
1807+
The **specific** trap GHSA-8hg8-63c5-gwmx flagged is the contradictory pair `{ nesting: true, require: false }`: a developer who sets `require: false` to lock down modules then enables `nesting: true` for legitimate child-VM use believes the sandbox is restricted. It is not. The deeper issue — `nesting: true` is fundamentally an escape hatch — is separately documented in the README.
1808+
1809+
CWE-284 (Improper Access Control).
1810+
1811+
### Attack Flow
1812+
1813+
1. **Host configures contradictory pair**: `new NodeVM({ nesting: true, require: false })`.
1814+
2. **Sandbox code requires `vm2`**: succeeds because `NESTING_OVERRIDE` injected `vm2` into the builtin map regardless of `require: false`.
1815+
3. **Sandbox constructs inner NodeVM** with attacker-chosen `require` config: `new NVM({ require: { builtin: ['child_process'] } })`.
1816+
4. **Inner sandbox loads `child_process`** and runs arbitrary commands as the host process user.
1817+
1818+
### Canonical Example
1819+
1820+
```javascript
1821+
// (advisory GHSA-8hg8-63c5-gwmx)
1822+
const vm = new NodeVM({ nesting: true, require: false });
1823+
vm.run(`
1824+
const { NodeVM: NVM } = require('vm2');
1825+
const inner = new NVM({ require: { builtin: ['child_process'] } });
1826+
module.exports = inner.run(
1827+
'module.exports = require("child_process").execSync("id").toString()'
1828+
);
1829+
`);
1830+
// uid=1000(...) ...
1831+
```
1832+
1833+
### Why It Works
1834+
1835+
The bug lives in `lib/resolver-compat.js` `makeResolverFromLegacyOptions`:
1836+
1837+
```javascript
1838+
function makeResolverFromLegacyOptions(options, override, compiler) {
1839+
if (!options) {
1840+
if (!override) return DENY_RESOLVER; // require:false alone → deny all
1841+
// require:false + nesting:true → permissive resolver with vm2 loadable:
1842+
const builtins = makeBuiltinsFromLegacyOptions(undefined, defaultRequire, undefined, override);
1843+
return new Resolver(DEFAULT_FS, [], builtins);
1844+
}
1845+
...
1846+
}
1847+
```
1848+
1849+
`require: false` makes `requireOpts` falsy; `nesting: true` passes `NESTING_OVERRIDE` as `override`. The `(!options && override)` branch builds a resolver containing the override (which carries `vm2`) instead of returning `DENY_RESOLVER`. `lib/builtin.js`'s `makeBuiltinsFromLegacyOptions` merges `override` unconditionally, so `vm2` always lands in the resolver's builtin map.
1850+
1851+
The reporter's framing — "mental-model mismatch" — is precise: there's no implementation bug in any individual line; the bug is the **interaction** between two options that look orthogonal but aren't.
1852+
1853+
### Mitigation
1854+
1855+
`NodeVM` constructor (`lib/nodevm.js`) throws `VMError` immediately when both `nesting: true` and `require: false` are set explicitly. Same shape as the GHSA-cp6g eager FileSystem-contract probe — surface contradictory configuration at construction with a clear, actionable error message citing the advisory and pointing to the README escape-hatch section. Enforces the principle that any configuration where vm2 cannot honor the developer's stated intent must fail loudly at the API surface, not produce a silently-permissive sandbox.
1856+
1857+
The narrow fix closes the **specific** contradictory pair. The **broader** issue — `nesting: true` is documented as an escape hatch and grants sandbox code unrestricted host access via inner NodeVMs — is now documented prominently in README § "`nesting: true` is an escape hatch" and in the JSDoc on the `nesting` option. Embedders running untrusted code should not enable `nesting: true`.
1858+
1859+
### Detection Rules
1860+
1861+
- **`new NodeVM({ nesting: true, ... })`** with any `require` setting in code reviewing untrusted-code flows — flag as a likely escape path.
1862+
- **`new NodeVM({ nesting: true, require: false })`** specifically — now throws at construction, but pre-3.11.1 codebases may have this pattern.
1863+
- **Sandbox code containing `require('vm2')`** — only reachable when `nesting: true`; almost always indicates an escape attempt unless the embedder explicitly built a VM-spawning host integration.
1864+
1865+
### Considered Attack Surfaces
1866+
1867+
- **`{ nesting: true, require: { builtin: ['something'] } }`** (no `require: false`) — does NOT throw. The developer has explicitly opted into the escape hatch by configuring a non-`false` require. The README and JSDoc loudly state that `nesting: true` is unsafe for untrusted code; this is a documentation-level mitigation, not a code-level one. Constraint propagation from outer to inner NodeVM (where the outer's `require` config would constrain inner construction) is out of scope for the 3.11.1 patch — it would change the documented semantics of `nesting: true` substantially.
1868+
- **Sandbox-side `require('vm2')` when `nesting: false`** — already throws `EDENIED` because the override is not installed. Unaffected.
1869+
- **`mocks` / `overrides`** — bypass the resolver entirely; unaffected by this fix and unaffected by `nesting: true` (mocks don't carry the `vm2` package).
1870+
1871+
---
1872+
18011873
## Considered Attack Surfaces
18021874
18031875
These attack surfaces were analyzed and found to be safe or low-risk. They are documented here so future reviewers do not re-investigate them.
@@ -1915,6 +1987,7 @@ The most dangerous attacks combine multiple categories. Each pattern references
19151987
| Array species self-return | set/defineProperty traps + neutralizeArraySpecies + SPECIES_ATTACK_SENTINEL |
19161988
| Host prepareStackTrace fallback | Safe default always set; setter resets to safe default instead of `undefined` |
19171989
| NodeVM `require.root` symlink bypass | `isPathAllowed` realpaths candidate before prefix check; `rootPaths` canonicalized at construction; deny-by-default if realpath throws |
1990+
| NodeVM `nesting: true` + `require: false` config trap | Constructor throws `VMError` at the contradictory option pair, citing GHSA-8hg8-63c5-gwmx and the README escape-hatch section |
19181991
19191992
### Key Security Invariant: Promise Species Resolution Timing
19201993

lib/nodevm.js

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,8 +230,15 @@ class NodeVM extends VM {
230230
* @param {customRequire} [options.require.customRequire=require] - Custom require to require host and built-in modules.
231231
* @param {boolean} [options.require.strict=true] - Load required modules in strict mode.
232232
* @param {boolean} [options.nesting=false] -
233-
* <b>WARNING: Allowing this is a security risk as scripts can create a NodeVM which can require any host module.</b>
234-
* Allow nesting of VMs.
233+
* <b>WARNING: <code>nesting: true</code> is an escape hatch — sandbox code
234+
* can <code>require('vm2')</code> and construct nested NodeVMs whose
235+
* <code>require</code> config is chosen by the sandbox, NOT constrained by
236+
* the outer config. Enabling <code>nesting: true</code> grants the sandbox
237+
* unrestricted host module access. Do not enable for untrusted code.
238+
* See README "`nesting: true` is an escape hatch".</b>
239+
* Combining <code>nesting: true</code> with <code>require: false</code>
240+
* throws <code>VMError</code> at construction (GHSA-8hg8-63c5-gwmx) — the
241+
* pair is contradictory.
235242
* @param {("commonjs"|"none")} [options.wrapper="commonjs"] - <code>commonjs</code> to wrap script into CommonJS wrapper,
236243
* <code>none</code> to retrieve value returned by the script.
237244
* @param {string[]} [options.sourceExtensions=["js"]] - Array of file extensions to treat as source code.
@@ -243,6 +250,28 @@ class NodeVM extends VM {
243250
* @throws {VMError} If the compiler is unknown.
244251
*/
245252
constructor(options = {}) {
253+
// SECURITY (GHSA-8hg8-63c5-gwmx): `nesting: true` injects a NESTING_OVERRIDE
254+
// builtin that exposes `vm2` to the sandbox regardless of `require: false`.
255+
// The sandbox then constructs an inner NodeVM with attacker-chosen `require`
256+
// config (unconstrained by the outer config — by design of nesting) and
257+
// reaches `child_process` for full host RCE. The contradictory option pair
258+
// is the specific trap the advisory describes; reject it at construction
259+
// with a clear error rather than silently producing an unsandboxed config.
260+
// (Bare `nesting: true` without `require: false` continues to work as the
261+
// documented escape hatch; the README "`nesting: true` is an escape hatch"
262+
// section explains the broader trade-off.)
263+
if (options.nesting === true && options.require === false) {
264+
throw new VMError(
265+
'NodeVM `nesting: true` is incompatible with `require: false`. '
266+
+ '`nesting: true` is an escape hatch that lets sandbox code '
267+
+ '`require(\'vm2\')` and construct nested VMs unconstrained by the outer '
268+
+ 'config — which contradicts `require: false`. To deny all requires, '
269+
+ 'remove `nesting: true`. To allow nested VMs, replace `require: false` '
270+
+ 'with an explicit config (e.g. `require: { builtin: [] }`) so the '
271+
+ 'tradeoff is visible. See README "`nesting: true` is an escape hatch". '
272+
+ 'Context: GHSA-8hg8-63c5-gwmx.'
273+
);
274+
}
246275
const {
247276
compiler,
248277
eval: allowEval,

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"alcatraz",
1414
"contextify"
1515
],
16-
"version": "3.11.0",
16+
"version": "3.11.1",
1717
"main": "index.js",
1818
"sideEffects": false,
1919
"repository": "github:patriksimek/vm2",
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* GHSA-8hg8-63c5-gwmx — `nesting: true` bypasses `require: false`
3+
*
4+
* ## Vulnerability
5+
* `new NodeVM({ nesting: true, require: false })` constructed a permissive
6+
* resolver containing the `NESTING_OVERRIDE` builtin (which exposes `vm2`)
7+
* despite `require: false`. Sandbox code could `require('vm2')`, construct
8+
* an inner `NodeVM` with attacker-chosen `require` config, and load
9+
* `child_process` for full host RCE. The mental-model mismatch — developer
10+
* sets `require: false` to lock down modules, then enables `nesting: true`
11+
* for legitimate child-VM use — silently produces an unsandboxed config.
12+
*
13+
* ## Fix
14+
* `NodeVM` constructor throws `VMError` immediately when both `nesting: true`
15+
* and `require: false` are set explicitly. Forces the developer to make a
16+
* deliberate choice: drop `nesting`, or replace `require: false` with an
17+
* explicit `require` config. Same shape as the cp6g eager FileSystem probe.
18+
*
19+
* ## Out of scope
20+
* `nesting: true` is fundamentally an escape hatch (sandbox can `require('vm2')`
21+
* and construct inner NodeVMs unconstrained by the outer config). This fix
22+
* closes the specific contradictory-config trap; the broader escape-hatch
23+
* nature of `nesting: true` is now documented prominently in README §
24+
* "`nesting: true` is an escape hatch".
25+
*/
26+
27+
'use strict';
28+
29+
const assert = require('assert');
30+
const { NodeVM, VMError } = require('../../../lib/main.js');
31+
32+
describe('GHSA-8hg8-63c5-gwmx — nesting: true bypasses require: false', () => {
33+
34+
it('rejects { nesting: true, require: false } at construction', () => {
35+
assert.throws(
36+
() => new NodeVM({ nesting: true, require: false }),
37+
err => err instanceof VMError
38+
&& /nesting/.test(err.message)
39+
&& /require/.test(err.message)
40+
&& /GHSA-8hg8-63c5-gwmx/.test(err.message),
41+
'construction should fail with a VMError citing nesting, require, and the advisory'
42+
);
43+
});
44+
45+
it('original PoC config is blocked at construction (cannot reach require(\'vm2\'))', () => {
46+
// Without the fix, this would succeed and the inner VM would execute
47+
// child_process.execSync('id'). With the fix, construction throws
48+
// before vm.run is ever called.
49+
assert.throws(() => {
50+
const vm = new NodeVM({ nesting: true, require: false });
51+
vm.run(`
52+
const { NodeVM: NVM } = require('vm2');
53+
const inner = new NVM({ require: { builtin: ['child_process'] } });
54+
module.exports = inner.run('module.exports = require("child_process").execSync("id").toString()');
55+
`);
56+
}, err => err instanceof VMError && /GHSA-8hg8-63c5-gwmx/.test(err.message));
57+
});
58+
59+
it('accepts { nesting: true, require: { builtin: [] } } (explicit empty allowlist)', () => {
60+
// Legitimate use: nesting enabled, no other host modules. Developer
61+
// has explicitly acknowledged that vm2 will be requireable.
62+
assert.doesNotThrow(() => new NodeVM({ nesting: true, require: { builtin: [] } }));
63+
});
64+
65+
it('accepts { nesting: true } alone (default require — escape-hatch use, documented)', () => {
66+
// Bare `nesting: true` continues to work as documented. The README
67+
// "`nesting: true` is an escape hatch" section explains the trade-off.
68+
// Not closed here (would require Option C constraint propagation —
69+
// out of scope for 3.11.1). This regression test ensures the narrow
70+
// fix doesn't accidentally break the bare-nesting case.
71+
assert.doesNotThrow(() => new NodeVM({ nesting: true }));
72+
});
73+
74+
it('accepts { require: false } alone (no nesting — deny all requires)', () => {
75+
// Existing behavior: require: false without nesting is fine — sandbox
76+
// truly cannot require anything. Regression guard.
77+
assert.doesNotThrow(() => new NodeVM({ require: false }));
78+
});
79+
80+
it('accepts { } (default config — no nesting, no require)', () => {
81+
// Regression guard for the most common case.
82+
assert.doesNotThrow(() => new NodeVM());
83+
});
84+
85+
});

0 commit comments

Comments
 (0)