Skip to content

v3.11.7

Latest

Choose a tag to compare

@patriksimek patriksimek released this 24 Aug 22:16

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 onRejected through Function.prototype.call / .apply indirection (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 likewise Date / RegExp / Map / boxed primitives — destructured to all-undefined and produced a NESTING_OVERRIDE-only resolver exposing host vm2 with no restriction → nested NodeVM → child_process (RCE).
  • GHSA-27g9-p43v-cw3v — Sandbox escape on Node 26 through a stale PromiseThenLookupChain protector: installing the then / catch wrappers by plain assignment left the protector valid on V8 14.6, so Promise.prototype.finally took an internal fast path to the original native then and an attacker constructor[Symbol.species] survived to capture a native reaction — reachable with eval: false and wasm: false (RCE).
  • GHSA-wjwh-qqvp-g4p4WebAssembly.compileStreaming / instantiateStreaming returned Promises whose prototype chain reached the host realm, so vm2's sandbox-side Promise overrides never ran on them; an attacker species plus finally() delivered the raw host rejection into sandbox code (RCE).
  • GHSA-fcqc-726x-5wfcBuffer.from([0]).buffer handed 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-xffxcrypto.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 only crypto (native RCE).
  • GHSA-6w8r-xxw2-g3hxnode:sqlite's DatabaseSync(':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-x7cmtls.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 through URLSearchParams.getAll() (host trust-store hijack).
  • GHSA-h85j-hv3c-qfgqhttp.globalAgent / https.globalAgent exposed the real shared host singleton, so a sandbox .on('free') listener received live host request options — including Authorization headers — and released sockets from unrelated host requests (host credential and traffic disclosure).
  • GHSA-qhwx-74w5-xhxqnode:test's run({ execArgv: ['--eval=<js>'] }) spawned a separate host Node process running attacker code with full host authority, from a sandbox with no filesystem or child_process of 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 the node:-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 only fs and left the full host fs/promises API exposed. The same gap affected every subpath family (-pathpath/posix, -streamstream/*, -timerstimers/promises).
  • GHSA-c48m-32m9-vx93 — External allowlist bypass with a custom require.resolve: the bare-specifier pre-check matched by substring, so external: ['left-pad'] also admitted evil-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 startsWith test, so a prefix-sharing sibling package (.../node_modules/foo2 for allowlisted foo) loaded as if it were allowlisted. Scoped names (@scope/pkg vs @scope/pkg-evil) were affected identically.
  • GHSA-j3hm-6rg5-mchv (dup: GHSA-w9c4-gw9x-53mq) — Sandboxed code under require.external could require('vm2') from disk and construct an unrestricted nested NodeVM, defeating the guarantee that nesting is off by default and reaching child_process (RCE).
  • GHSA-jxxv-8r27-vm4p — The shipped CLI (npx vm2 ./script.js) ran the target with no effective boundary: NodeVM.file was constructed with no require.root and the default host context, so the script could require(__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 host nodejs.stream.disturbed / errored symbols and defineProperty them onto a host stream, flipping isDisturbed() / isErrored() host-side on an already-consumed stream (host state corruption).
  • GHSA-633r-hq9m-c4ffvm.freeze() / vm.readonly() bypass: a frozen host object's accessor set was still reachable via Object.getOwnPropertyDescriptor(...).set, __lookupSetter__, Reflect.getOwnPropertyDescriptor and getOwnPropertyDescriptors, letting sandbox code mutate state through a view the embedder declared read-only.
  • GHSA-r4fx-v8hh-22mvtimeout bypass via FinalizationRegistry cleanup 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 SyntaxError raised by the transformer had its stack formatted host-side, so the sandbox-realm redaction never ran and e.stack disclosed absolute host paths — vm2's own files, Node internals, and the embedding application's source (information disclosure).

Documentation

  • docs/ATTACKS.md extended 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 usersnpx vm2 ./script.js now 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 with node directly if that was the intent.
  • builtin: ['*'] users depending on node:test — the test family is now denied, including subpaths such as node:test/reporters, and is refused on explicit request. Its run({ execArgv }) spawns a host process, so it cannot be exposed safely; re-introduce a narrowed wrapper via require.mock / require.override if needed.
  • Builtin deny tokens are now stricter. -fs also denies fs/promises (and equivalently for every subpath family), and -node:x now matches the canonical x spelling. If you relied on a subpath surviving its family's deny token, list it explicitly.
  • FinalizationRegistry and WeakRef are no longer sandbox globals by default. Sandbox code referencing them now sees undefined. Re-expose via the sandbox option only if you accept that GC callbacks are not bounded by timeout.
  • WebAssembly.compileStreaming / instantiateStreaming are removed from the sandbox. Non-streaming WebAssembly.compile / instantiate / Module / Memory are unaffected.
  • Host error stacks reaching sandbox catch blocks now have host frames redacted. The error message is preserved; absolute host paths, Node internals and file: / 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.external without require.root now 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. Set require.root, or context: 'sandbox', or both.
  • NodeVM({ nesting: <truthy> }) with an array-shaped or other non-config require now throws at construction, alongside the shapes already rejected by GHSA-m4wx-m65x-ghrr. Pass a plain config object (e.g. require: { builtin: [] }) or a Resolver.

Full Changelog: 3.11.6...v3.11.7