Twenty advisories closed. Patch release — no API changes, but several observable behaviour changes; see Upgrade Notes.
What's Changed
Security fixes
- GHSA-647f-g98j-qq25 — Patch bypass of the GHSA-m283-3h24-438v host-Promise rejection sanitizer: the apply-trap gate identity-checked only the direct apply target, so registering
onRejectedthroughFunction.prototype.call/.applyindirection (including stacked and mixed forms) skipped the capability-stripping rebuild and delivered the raw host rejection to sandbox code (RCE). - GHSA-8hr7-r645-pc6w — Patch bypass of the GHSA-m4wx-m65x-ghrr nesting guard: the guard accepted any
typeof requireOpts === 'object'value, so{ nesting: true, require: [] }— and likewiseDate/RegExp/Map/ boxed primitives — destructured to all-undefinedand produced aNESTING_OVERRIDE-only resolver exposing hostvm2with no restriction → nested NodeVM →child_process(RCE). - GHSA-27g9-p43v-cw3v — Sandbox escape on Node 26 through a stale
PromiseThenLookupChainprotector: installing thethen/catchwrappers by plain assignment left the protector valid on V8 14.6, soPromise.prototype.finallytook an internal fast path to the original nativethenand an attackerconstructor[Symbol.species]survived to capture a native reaction — reachable witheval: falseandwasm: false(RCE). - GHSA-wjwh-qqvp-g4p4 —
WebAssembly.compileStreaming/instantiateStreamingreturned Promises whose prototype chain reached the host realm, so vm2's sandbox-side Promise overrides never ran on them; an attacker species plusfinally()delivered the raw host rejection into sandbox code (RCE). - GHSA-fcqc-726x-5wfc —
Buffer.from([0]).bufferhanded the sandbox Node's shared 64 KB allocation pool, which backs unrelated host allocations — sandbox code could read host secrets out of neighbouring buffers and corrupt them in place (host memory disclosure and corruption). - GHSA-46pr-c5wc-xffx —
crypto.setEngine(path)passed a sandbox-supplied path to OpenSSL's ENGINE loader, and the OS dynamic loader ran the named library's constructor as native code before OpenSSL validated the file — reachable from a NodeVM allowing onlycrypto(native RCE). - GHSA-6w8r-xxw2-g3hx —
node:sqlite'sDatabaseSync(':memory:', { allowExtension: true }).loadExtension(path)loaded a native SQLite extension into the host process, reachable from a NodeVM allowing only that builtin;require('node:node:sqlite')was additionally accepted as a second spelling (native RCE). - GHSA-98xx-8mx4-x7cm —
tls.setDefaultCACertificates()let sandbox code replace the host thread's process-wide default CA trust store, so subsequent host TLS clients accepted attacker-signed certificates; the required host array was forgeable throughURLSearchParams.getAll()(host trust-store hijack). - GHSA-h85j-hv3c-qfgq —
http.globalAgent/https.globalAgentexposed the real shared host singleton, so a sandbox.on('free')listener received live host request options — includingAuthorizationheaders — and released sockets from unrelated host requests (host credential and traffic disclosure). - GHSA-qhwx-74w5-xhxq —
node:test'srun({ execArgv: ['--eval=<js>'] })spawned a separate host Node process running attacker code with full host authority, from a sandbox with no filesystem orchild_processof its own (RCE). - GHSA-8686-vhfx-7r3j — NodeVM's
builtin: ['*', '-node:child_process']deny token was a silent no-op: the wildcard expansion matched deny tokens by exact string, so thenode:-prefixed spelling never matched the canonical name and the host module stayed exposed (RCE). - GHSA-6rh5-qq4q-97xh — Builtin deny tokens did not cover subpath siblings:
builtin: ['*', '-fs']removed onlyfsand left the full hostfs/promisesAPI exposed. The same gap affected every subpath family (-path→path/posix,-stream→stream/*,-timers→timers/promises). - GHSA-c48m-32m9-vx93 — External allowlist bypass with a custom
require.resolve: the bare-specifier pre-check matched by substring, soexternal: ['left-pad']also admittedevil-left-pad; anchoring that left a second route, since the permitted subpath tail accepted..segments. Either way an un-allowlisted host package ran its top-level code in host context (RCE). - GHSA-7q3f-wx44-378m — External allowlist authorized requires with a raw
startsWithtest, so a prefix-sharing sibling package (.../node_modules/foo2for allowlistedfoo) loaded as if it were allowlisted. Scoped names (@scope/pkgvs@scope/pkg-evil) were affected identically. - GHSA-j3hm-6rg5-mchv (dup: GHSA-w9c4-gw9x-53mq) — Sandboxed code under
require.externalcouldrequire('vm2')from disk and construct an unrestricted nestedNodeVM, defeating the guarantee that nesting is off by default and reachingchild_process(RCE). - GHSA-jxxv-8r27-vm4p — The shipped CLI (
npx vm2 ./script.js) ran the target with no effective boundary:NodeVM.filewas constructed with norequire.rootand the default host context, so the script couldrequire(__filename)— or any absolute path — and execute it in the host realm (RCE). - GHSA-jf8q-945g-9q4c — Incomplete
nodejs.*symbol filtering let sandbox code extract real hostnodejs.stream.disturbed/erroredsymbols anddefinePropertythem onto a host stream, flippingisDisturbed()/isErrored()host-side on an already-consumed stream (host state corruption). - GHSA-633r-hq9m-c4ff —
vm.freeze()/vm.readonly()bypass: a frozen host object's accessorsetwas still reachable viaObject.getOwnPropertyDescriptor(...).set,__lookupSetter__,Reflect.getOwnPropertyDescriptorandgetOwnPropertyDescriptors, letting sandbox code mutate state through a view the embedder declared read-only. - GHSA-r4fx-v8hh-22mv —
timeoutbypass viaFinalizationRegistrycleanup callbacks, which run outside the timeout's reach and block the host event loop for an unbounded period (host availability). - GHSA-x6m4-chr9-cg97 — Patch bypass of the GHSA-v27g-jcqj-v8rw host-path redaction: a host-realm
SyntaxErrorraised by the transformer had its stack formatted host-side, so the sandbox-realm redaction never ran ande.stackdisclosed absolute host paths — vm2's own files, Node internals, and the embedding application's source (information disclosure).
Documentation
docs/ATTACKS.mdextended with Categories 39–48, plus extensions to Categories 8, 21, 25 and 33 and matching rows in the "How The Bridge Defends" table.
Upgrade Notes
- CLI users —
npx vm2 ./script.jsnow confines requires to the target script's own directory (root: dirname(script)) and loads them inside the sandbox (context: 'sandbox'). A script that previously reached host modules through the CLI will no longer do so; run it withnodedirectly if that was the intent. builtin: ['*']users depending onnode:test— thetestfamily is now denied, including subpaths such asnode:test/reporters, and is refused on explicit request. Itsrun({ execArgv })spawns a host process, so it cannot be exposed safely; re-introduce a narrowed wrapper viarequire.mock/require.overrideif needed.- Builtin deny tokens are now stricter.
-fsalso deniesfs/promises(and equivalently for every subpath family), and-node:xnow matches the canonicalxspelling. If you relied on a subpath surviving its family's deny token, list it explicitly. FinalizationRegistryandWeakRefare no longer sandbox globals by default. Sandbox code referencing them now seesundefined. Re-expose via thesandboxoption only if you accept that GC callbacks are not bounded bytimeout.WebAssembly.compileStreaming/instantiateStreamingare removed from the sandbox. Non-streamingWebAssembly.compile/instantiate/Module/Memoryare unaffected.- Host error stacks reaching sandbox
catchblocks now have host frames redacted. The error message is preserved; absolute host paths, Node internals andfile:/wasm:frames are gone by design. Sandbox-realm frames still appear. vm.freeze()/vm.readonly()no longer expose accessor setters through any descriptor-reading path. Getter reads are preserved; non-frozen host objects are unaffected.require.externalwithoutrequire.rootnow emits a one-time warning. The breadth of that option is unchanged in this release — it still host-requires any named path — and tightening it to deny-by-default is a breaking change deferred to the next major. Setrequire.root, orcontext: 'sandbox', or both.NodeVM({ nesting: <truthy> })with an array-shaped or other non-configrequirenow throws at construction, alongside the shapes already rejected by GHSA-m4wx-m65x-ghrr. Pass a plain config object (e.g.require: { builtin: [] }) or aResolver.
Full Changelog: 3.11.6...v3.11.7