Skip to content

Latest commit

 

History

History
211 lines (142 loc) · 58.7 KB

File metadata and controls

211 lines (142 loc) · 58.7 KB

Changelog

[3.12.2]

Security fixes

  • GHSA-5h3f-q97h-ccvc — NodeVM custom-resolver authorization admitted prefix-sharing siblings: resolving an allowlisted package recorded a raw ^<path> prefix, so .../node_modules/foo authorized .../node_modules/foo2/index.js (and the {module, path} shape authorized the whole search directory), running the sibling's top-level code in the host realm under context: 'host'. Structural fix in lib/resolver-compat.js: resolver answers are recorded as boundary-matched base paths (plus exact extension spellings), the object shape authorizes only the resolved package directory, and a failed load withdraws its authorization. Behavior change: a {module, path} answer whose module is absolute, relative or contains .. is now refused. See ATTACKS.md Category 46 and test/ghsa/GHSA-5h3f-q97h-ccvc/.
  • GHSA-2v2p-6j97-cjg9 — a host promise delivered into the sandbox through a constructor return, a host getter or data property, or a callback argument carried no rejection reaction, so sandbox code that dropped it terminated the host process under Node's unhandledRejection policy (GHSA-gjq8-xm47-88rc covered only apply returns). Structural fix in lib/bridge.js: every host promise is marked handled once at the delivery chokepoint (prototype brand check, cross-realm aware), and the construct trap gains the same unconditional mark as apply. Behavior change: embedders no longer see unhandledRejection for host promises handed to the sandbox on any route; debug rejections need an explicit .catch(). See ATTACKS.md Category 22 and test/ghsa/GHSA-2v2p-6j97-cjg9/.
  • GHSA-489w-w794-jq94 — host memory disclosure and corruption: a host-allocated Buffer (a builtin's return value such as zlib.deflateSync, an embedder-supplied buffer, a callback argument) exposed Node's shared 64 KiB pool through .buffer / .parent, letting the sandbox read and overwrite unrelated host buffers. Structural fix in lib/bridge.js: the GHSA-fcqc backing-store ownership rule now applies at the bridge for every host view, keyed on the delivered value's identity (so every alias of the store is covered), with the raw buffer / parent / offset getters undeliverable and a fail-closed gate. Behavior change: for a host view that does not own its whole store, .buffer / .parent is a bounded copy (not identity-stable, not write-through), byteOffset / offset read as 0, sandbox-created sub-views lose .buffer aliasing with their parent, and SharedArrayBuffer sub-views are delivered as copies; owning buffers are unchanged. See ATTACKS.md Category 41 and test/ghsa/GHSA-489w-w794-jq94/.

Fixed

  • Single-file bundlers (Bun compile, esbuild, pkg, ...) can now ship vm2. The sandbox bootstrap files (bridge.js, setup-sandbox.js, setup-node-sandbox.js, events.js) must reach the sandbox realm as source text and were read from disk at runtime with fs.readFileSync(\${__dirname}/...`), which a bundler cannot follow — a compiled binary failed with ENOENTas soon as the package directory was not on disk. They are now embedded as string literals in the generatedlib/sources.js (npm run build:sources, regenerated by pretest/prepublishOnlyand guarded by a staleness test). The sandbox-compiled scripts use the fixed virtual filename/vm2/lib/` instead of the host install path, so bootstrap frames stay redacted from sandbox-visible stack traces.

Maintenance

  • Dev dependency @humanfs/node bumped from 0.16.6 to 0.16.8.

[3.12.1]

Security fixes

  • GHSA-6454-5x88-m6jw — sandbox-to-host RCE through an embedder-exposed host Promise. Writing constructor[Symbol.species] on the raw host promise and calling .then / .catch / .finally with the settlement-direction handler omitted made V8 deliver the raw host settlement (e.g. process) to a sandbox-captured capability, with no callback slot for the rejection sanitizer to wrap. Structural fix in lib/bridge.js: neutralizeHostPromiseSpeciesOn shadows the host promise's constructor across the call so the result capability is always a genuine host %Promise%, and the indirection peel now also covers host Reflect.apply and .finally. See ATTACKS.md Category 53 and test/ghsa/GHSA-6454-5x88-m6jw/.
  • GHSA-j89j-5m6r-cr2q — sandbox escape to host RCE through any embedder-exposed sloppy-mode host function. Calling it with a nullish receiver (greet(), .call(null), Reflect.apply(fn, undefined, []), bind(null)()) makes V8 bind this to the host realm's global object, which the bridge then wrapped and delivered to the sandbox (greet().process.getBuiltinModule('child_process')). Structural fix in lib/bridge.js: the host global is cached at bridge init and refused at the three host→sandbox coercion chokepoints, returning undefined so strict-function semantics are preserved. See ATTACKS.md Category 54 and test/ghsa/GHSA-j89j-5m6r-cr2q/.
  • GHSA-x3v6-43hc-82mc — a NodeVM that allowlists the crypto builtin let guest code call crypto.setFips, flipping the FIPS mode of the entire host process; the read-only wrap stops property writes but forwards host calls with full authority, the same process-wide-mutator class as crypto.setEngine and tls.setDefaultCACertificates. Fix in lib/builtin.js: sanitizeCryptoModule replaces setFips with a throwing stub alongside setEngine; getFips() and the rest of crypto are untouched. Configuration-integrity issue, not RCE. See ATTACKS.md Category 40 and test/ghsa/GHSA-x3v6-43hc-82mc/.
  • GHSA-pq68-rvw4-xp4r — NodeVM's hard denylist omitted child_process, so require: { builtin: ['*'] }, ['*', '-fs'], an explicit ['child_process'], and the node: spellings all handed the sandbox the real host module and one execSync call was host RCE. Fix in lib/builtin.js: child_process joins DANGEROUS_BUILTINS, denied under the wildcard and on explicit request like cluster / worker_threads / node:test. Behavior change: embedders running trusted scripts that need it re-expose it through require.mock (the real module or a narrower facade); a bare builtin: ['child_process'] no longer grants it. See ATTACKS.md Category 21 and test/ghsa/GHSA-pq68-rvw4-xp4r/.

[3.12.0]

Added

  • Experimental Bun support (test suite and CI only). The suite now runs under Bun, with engine-keyed assertion messages so patterns stay exactly as strict on Node, a central test/bun-skips.js listing every JavaScriptCore divergence, and a non-blocking CI job whose output is verified complete before it is believed. Bun is not a supported security boundary — vm2's threat model is derived from V8 internals and JavaScriptCore has not been audited against the bridge. See the README Runtimes section.

Maintenance

  • global.Proxy install guarded for JavaScriptCore, and the sealed-slot attributes left implicit — lib/setup-sandbox.js installs the handler-sanitising Proxy with a bare assignment to a slot the Object.defineProperties(global, ...) block above declares as undefined. What that write does is engine-specific: on Node >= 10 the slot is genuinely sealed and the write is a silent no-op, so the sandbox has no Proxy at all; on Node 8 the old V8 global proxy leaves the slot writable and the write is live, which is what gives that sandbox its Proxy; and JavaScriptCore (Bun) implements the strict-mode write correctly and throws, aborting sandbox setup before the first run(). The write is now wrapped in try/catch — the failure outcome is "no Proxy in the sandbox", which is strictly more restrictive, never less. test/vm.js gains an AST-based guard (via acorn, already a runtime dependency) that fails if any write to Error, Promise or Proxy is left outside a try block, including through a local alias, and pins the sealed-slot descriptors on Node >= 10 where the seal actually takes. Correction to 3.11.8's entry: that release described the assignment as dead code and removed it, and separately spelled out writable: false, configurable: false on the three sealed slots for readability, claiming no behaviour change. The second change was the damaging one — those attributes are the spec defaults, so the forms are equivalent on a current V8 but not on the Node 8 global proxy, where only the explicit form actually seals the slot. That silently removed Proxy from Node 8 sandboxes and broke six tests. The attributes are omitted again, with a comment saying why they must stay that way, and the assignment is restored.

[3.11.8]

Security fixes

  • GHSA-3vgf-8m4q-q4qr (dup: GHSA-59g5-pmg6-5gr4) — default VM host intrinsic prototype pollution of the binary-data and iterator families. The protected inventory omitted ArrayBuffer / SharedArrayBuffer / DataView / every TypedArray / the abstract %TypedArray%.prototype, and the array/string/map/set/regexp-string iterator prototypes plus the shared %IteratorPrototype%. Because Buffer extends Uint8Array, the Category 20 proto-walk from a host Buffer reached those unprotected host prototypes and Reflect.defineProperty polluted them globally, corrupting every host-realm typed array and iterator. lib/bridge.js now lists the binary-data globals in globalsList and resolves the abstract intrinsic prototypes structurally into thisGlobalPrototypes, routing all of them into protectedHostObjects, the proto-mapping table, and the identity map so the write traps refuse sandbox set/defineProperty. See ATTACKS.md Category 20 (extended) and test/ghsa/GHSA-3vgf-8m4q-q4qr/.
  • GHSA-88hf-g992-jg85 — NodeVM default-config (console: 'inherit') sandbox escape. The sandbox extracted the raw host Object.prototype.__proto__ getter (via Buffer.call.call(__lookupGetter__, …, '__proto__'), the GHSA-v6mx/cfcw primitive) and, because that getter was never classified dangerous like the setter, climbed console._stdout's host prototype chain to the non-intrinsic EventEmitter.prototype, overwrote emit, and had the host invoke it with this === process → RCE. Closed with two independent layers in lib/bridge.js: (1) the raw host proto-readers (__proto__ getter, Object.getPrototypeOf, Reflect.getPrototypeOf) are denied delivery at thisFromOtherWithFactory/thisEnsureThis/thisFromOtherForThrow and the apply trap, so the sandbox can no longer climb host chains; (2) host [[Prototype]] objects are marked at delivery and sandbox function/accessor writes to them are diverted off the raw host object in BaseHandler.set/defineProperty. Legitimate Object.getPrototypeOf on host proxies and data/leaf writes are unchanged. See ATTACKS.md Category 50 and test/ghsa/GHSA-88hf-g992-jg85/.
  • GHSA-f8gf-w286-fmq2 — allowAsync: false async-execution boundary bypassed via Promise thenable assimilation. Blocking Promise.prototype.then left every native resolve capability open: Promise.resolve/all/race/any/allSettled/try, new Promise(r => r(thenable)), withResolvers().resolve, Array.fromAsync, and the realm-intrinsic base reached via Object.getPrototypeOf(Promise) all let V8's PromiseResolveThenableJob run an attacker .then in a microtask after run() returned, outside the configured timeout. Structural fix in lib/setup-sandbox.js, gated entirely to allowAsync: false: a TOCTOU-safe resolve-capability guard (refuses object/function values without ever reading .then), synchronous throws on the assimilating static methods, a non-configurable throwing Array.fromAsync stub, and a construct-guard Proxy on localPromise's prototype that makes the native base un-constructable from the sandbox. allowAsync: true is untouched. See ATTACKS.md Category 51 and test/ghsa/GHSA-f8gf-w286-fmq2/.
  • GHSA-gjq8-xm47-88rc — an embedder-exposed host function (or a host builtin such as events.once) that returns a rejected host Promise crashed the entire host process when sandbox code called it and ignored the result. The bridge handed the sandbox a wrapped promise but left the underlying host promise without a rejection reaction of its own, so Node's default unhandledRejection policy (Node 15+) tore the process down — a sandbox-triggered host DoS from a single line of untrusted code. Sibling of the parent advisory GHSA-hw58-p9xv-2mjh, which hardened the opposite (sandbox→host) direction. lib/bridge.js now attaches a benign no-op reaction to the underlying host promise on the host side, at the apply-trap boundary (markHostPromiseHandled). Promises multicast, so the sandbox's own GHSA-55hx-sanitized .then/.catch still fires and still observes the sanitized rejection; the no-op onRejected returns undefined, so it never creates a new unhandled rejection; fulfilled promises are untouched. See ATTACKS.md Category 22 (extended) and test/ghsa/GHSA-gjq8-xm47-88rc/.
  • GHSA-r273-hxvj-fxhp — NodeVM exposed host util to the sandbox as an unfiltered Object.assign({}, util), so util.getCallSites() (Node >= 22.9) handed sandboxed code the host process call stack — absolute paths including vm2's own lib/ and the embedder entrypoint — bypassing the GHSA-v27g host-frame redaction, which only covers sandbox-realm Error stacks. getCallSite / setTraceSigInt / private internals rode the same wholesale copy, and the sys alias leaked identically via the generic loader. lib/builtin.js now builds the exposed util from a vetted, forward-safe allowlist (SAFE_UTIL_MEMBERS, presence-gated Node 8→26) routed through the BUILTIN_MEMBER_SANITIZERS chokepoint for both util and sys, so no unreviewed host member reaches the sandbox. Information disclosure only. See ATTACKS.md Category 52 and test/ghsa/GHSA-r273-hxvj-fxhp/.
  • GHSA-x965-fc75-jpqh — incomplete-fix bypass of GHSA-m283-3h24-438v: a host-wrapped AggregateError/SuppressedError revisited within one handleException traversal (self-cycle agg.errors=[agg], duplicate [shared,shared], mutual cycle) returned the raw host carrier from the cycle memo, re-embedding a live host proxy into the rebuilt errors[] → host RCE on the caught-exception channel. lib/setup-sandbox.js now memoizes each carrier to exactly what a revisit must return (itself when sealed in place, its sandbox-realm replacement when rebuilt), builds host-wrapped aggregate/suppressed replacements in two phases so every cycle terminates on the replacement, memoizes the sanitizeHostOwnProps rebuild, and adds a _blockHostWrapped backstop at embed sites. See ATTACKS.md Category 49 and test/ghsa/GHSA-x965-fc75-jpqh/.

[3.11.7]

Security fixes

  • GHSA-27g9-p43v-cw3v — VM sandbox escape on Node 26 via a stale PromiseThenLookupChain protector. vm2 installed its Promise.prototype.then / catch wrappers by plain assignment; on V8 14.6 that updates the data property without invalidating the protector, so Promise.prototype.finally took an internal InvokeThen fast path straight to the original native then and vm2's wrapper — and its resetPromiseSpecies — never ran. An attacker constructor[Symbol.species] on an ordinary async-function Promise therefore survived p.finally() and took control of a native reaction, which a calibrated stack overflow turned into a raw host-realm RangeError (e.constructor.constructor → host Function → host process), reachable with eval: false and wasm: false. lib/setup-sandbox.js now installs the then / catch wrappers through Reflect.defineProperty (which does invalidate the protector) and wraps Promise.prototype.finally to run resetPromiseSpecies(this) before delegating to the cached native implementation. See ATTACKS.md Category 43 and test/ghsa/GHSA-27g9-p43v-cw3v/.
  • GHSA-46pr-c5wc-xffx — crypto.setEngine(path) handed 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 rejected the file — native RCE from a NodeVM allowing only crypto. lib/builtin.js now neutralizes the member before the read-only wrap, so no library is ever loaded; the rest of crypto is untouched. See ATTACKS.md Category 40 and test/ghsa/GHSA-46pr-c5wc-xffx/.
  • GHSA-633r-hq9m-c4ff — vm.freeze() / vm.readonly() read-only bypass: a frozen host object's accessor set was still reachable from the sandbox via Object.getOwnPropertyDescriptor(...).set, __lookupSetter__, Reflect.getOwnPropertyDescriptor and Object.getOwnPropertyDescriptors, letting sandbox code mutate host state through a view the embedder declared read-only. Fixed in lib/bridge.js by stripping set in ReadOnlyHandler.getOwnPropertyDescriptorDesc and routing doPreventExtensions descriptors through that same hook; getter reads are preserved and non-frozen host objects are unaffected. See ATTACKS.md Category 44 and test/ghsa/GHSA-633r-hq9m-c4ff/.
  • GHSA-647f-g98j-qq25 — patch bypass of the GHSA-m283-3h24-438v host-Promise rejection sanitizer, allowing sandbox-to-host RCE. The apply-trap gate identity-checked only the direct apply target, so registering an onRejected handler through Function.prototype.call/.apply indirection skipped the capability-stripping rebuild and delivered the raw host rejection to sandbox code. lib/bridge.js now peels call/apply indirection to the effective host then/catch and wraps the callbacks regardless of invocation shape, snapshotting .apply argument arrays getter-free and failing closed past a bounded peel depth. ATTACKS.md Category 39; tests in test/ghsa/GHSA-647f-g98j-qq25/.
  • GHSA-6rh5-qq4q-97xh — NodeVM builtin deny tokens did not cover subpath siblings: fs and fs/promises are separate builtinModules entries, so builtin: ['*', '-fs'] removed only fs and left the full host fs/promises API (host filesystem writes via writeFile) exposed. The same gap affected every subpath family (-path → path/posix, -stream → stream/*, -timers → timers/promises). The '*' deny check in lib/builtin.js now routes through isBuiltinDenied, which treats <family>/<sub> as denied whenever -<family> is present, in either node: spelling; undenied families keep their subpaths. See ATTACKS.md Category 21 and test/ghsa/GHSA-6rh5-qq4q-97xh/.
  • GHSA-6w8r-xxw2-g3hx — node:sqlite's DatabaseSync(':memory:', { allowExtension: true }).loadExtension(path) loaded a native SQLite extension into the host process — native RCE from a NodeVM allowing only that builtin. The exposed DatabaseSync now forces allowExtension off (for object- and function-typed options alike, since Node accepts a function there), so Node throws ERR_INVALID_STATE from both loadExtension() and enableLoadExtension() while ordinary SQL keeps working. lib/setup-node-sandbox.js additionally rejects repeated node: prefixes, closing the require('node:node:sqlite') second spelling and making the canonical require('node:sqlite') resolve. See ATTACKS.md Category 40 and test/ghsa/GHSA-6w8r-xxw2-g3hx/.
  • GHSA-7q3f-wx44-378m — NodeVM external allowlist bypass: a prefix-sharing sibling package loaded as allowlisted. LegacyResolver.isPathAllowedForModule authorized a require from an allowlisted module with a raw path.startsWith(mod.path) test, so .../node_modules/foo2/index.js passed for allowlisted .../node_modules/foo. lib/resolver-compat.js now requires a path boundary after mod.path (exact match / trailing separator / next char a separator), so foo2 no longer matches foo. Scoped names (@scope/pkg vs @scope/pkg-evil) were affected identically and are covered. See ATTACKS.md Category 46 and test/ghsa/GHSA-7q3f-wx44-378m/.
  • 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 child_process name and the host module (RCE via execSync/spawn) stayed exposed. The '*' deny check in lib/builtin.js now matches both -${name} and -node:${name}, mirroring the node: normalization the resolver already applies on the require side. See ATTACKS.md Category 21 and test/ghsa/GHSA-8686-vhfx-7r3j/.
  • GHSA-8hr7-r645-pc6w — patch-bypass of the GHSA-m4wx-m65x-ghrr NodeVM nesting guard. The guard accepted any typeof requireOpts === 'object' value as a real require config, so { nesting: true, require: [] } (an array — and likewise Date/RegExp/Map/boxed primitives) passed it, destructured to all-undefined, and produced a NESTING_OVERRIDE-only resolver that exposes host vm2 to the sandbox with no restriction → nested NodeVM → child_process → host RCE. The guard now accepts only a Resolver or a plain config object (Object.prototype/null prototype, not an array) via a shared isPlainConfigObject predicate, enforced at two layers: the lib/nodevm.js constructor throws for non-config shapes under nesting, and lib/resolver-compat.js makeResolverFromLegacyOptions fail-closed strips the nesting override for any non-plain options so no alternate caller can re-open it. The documented escape hatch ({ nesting: true, require: {} } / { builtin: [...] }) is unchanged. See ATTACKS.md Category 25 and test/ghsa/GHSA-8hr7-r645-pc6w/.
  • 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. Argument-side defenses were insufficient (the required host array is forgeable through URLSearchParams.getAll()), so the member itself is now neutralized in lib/builtin.js; the rest of tls is unaffected. See ATTACKS.md Category 40 and test/ghsa/GHSA-98xx-8mx4-x7cm/.
  • GHSA-c48m-32m9-vx93 — NodeVM require.external allowlist bypass when a custom require.resolve is configured. The bare-specifier pre-check in LegacyResolver.customResolve matched by substring, so external: ['left-pad'] also admitted evil-left-pad / left-pad-evil; anchoring that matcher then left a second route, since the permitted subpath tail accepted .. segments (left-pad/../evil-package). Either way the resolver located an un-allowlisted host package and, under the default context: 'host', ran its top-level code in host context. Two composed layers in lib/resolver-compat.js: the externalCache matcher is anchored to the whole specifier (wildcard segment semantics preserved), and any bare specifier carrying a .. path segment is rejected before the custom resolver is consulted. See ATTACKS.md Category 45 and test/ghsa/GHSA-c48m-32m9-vx93/.
  • GHSA-fcqc-726x-5wfc — sandbox read/write of host-realm memory through Node's shared small-buffer pool. Node serves small Buffer.from(...) / Buffer.concat(...) / Buffer.of(...) allocations out of one shared 64 KiB backing ArrayBuffer, and a pooled buffer's .buffer getter exposed that whole pool — so Buffer.from(Buffer.from([0]).buffer, 0, 65536) inside the sandbox could disclose and corrupt any host buffer (secrets, tokens, DB rows) sharing it. lib/setup-sandbox.js now enforces a backing-store ownership invariant (byteOffset === 0 && buffer.byteLength === length): every pooling factory (Buffer.from non-ArrayBuffer overloads, concat, of, copyBytesFrom, and the deprecated Buffer(...) / new Buffer(...) forms) copies a pool-backed result into a standalone non-pooled buffer, while the documented Buffer.from(arrayBuffer, byteOffset, length) sharing overload is preserved via a spoof-proof brand test. Independent of bufferAllocLimit. See ATTACKS.md Category 41 and test/ghsa/GHSA-fcqc-726x-5wfc/.
  • GHSA-h85j-hv3c-qfgq — http.globalAgent / https.globalAgent handed the sandbox the real shared host singleton, so a .on('free') listener received live host request options (including Authorization headers) and released sockets from unrelated host requests. The sandbox now sees a dedicated Agent, and the module's request() / get() default to it so req.agent cannot re-expose the host singleton; a caller-supplied agent is preserved. See ATTACKS.md Category 40 and test/ghsa/GHSA-h85j-hv3c-qfgq/.
  • 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. lib/resolver-compat.js now denies a sandbox require() of vm2's own lib/ directory and package main entry, matched by realpath so a symlinked candidate cannot dodge it; nesting: true is unaffected, as it uses the builtin-override mechanism rather than a file require. Note that require.external without require.root still host-requires any named path — the documented meaning of that option — and now warns once; tightening it to deny-by-default is a breaking change deferred to the next major. See ATTACKS.md Category 47 and test/ghsa/GHSA-j3hm-6rg5-mchv/.
  • GHSA-jf8q-945g-9q4c — incomplete nodejs.* symbol filtering let sandbox code corrupt host-visible WebStream state. The dangerous cross-realm symbol checks were fixed lists that omitted nodejs.stream.disturbed / nodejs.stream.errored, so sandbox code could extract those real host symbols from a ReadableStream.prototype reachable through the sandbox and defineProperty them onto a host stream, flipping stream.Readable.isDisturbed() / isErrored() host-side on an already-consumed stream. isDangerousSymbol (lib/setup-sandbox.js) and isDangerousCrossRealmSymbol (lib/bridge.js) now flag any registered symbol whose Symbol.keyFor is in the reserved nodejs. namespace, covering the extraction filter, the getOwnPropertyDescriptors scrub and the set / defineProperty / deleteProperty write traps, so the guard no longer goes stale as Node adds nodejs.* symbols; well-known symbols and benign registered symbols still cross. See ATTACKS.md Category 8 (extended) and test/ghsa/GHSA-jf8q-945g-9q4c/.
  • GHSA-jxxv-8r27-vm4p — the shipped CLI (npx vm2 ./script.js) ran the target script with no sandbox boundary. lib/cli.js built NodeVM.file(path, {require: {external: true}}) with no require.root and the default require.context: 'host', so the script could require(__filename) — or any absolute path — and execute it in the host realm with full host authority. The CLI now confines requires to the script's own directory (root: pa.dirname(script)) and loads them inside the sandbox (context: 'sandbox'). See ATTACKS.md Category 47 and test/ghsa/GHSA-jxxv-8r27-vm4p/.
  • GHSA-qhwx-74w5-xhxq — NodeVM builtin: ['node:test'] (and ['*']) exposed the real host node:test, whose run({ execArgv: ['--eval=<js>'] }) spawns a separate host Node process running attacker code with full host authority — RCE from a sandbox with no fs or child_process of its own. lib/builtin.js adds test to DANGEROUS_BUILTINS, so the family (including node:test/reporters) is dropped from wildcard expansion, refused on explicit request, and never enters the builtins map; isDangerousBuiltin now strips repeated node: prefixes so node:node:test normalizes too. See ATTACKS.md Category 21 and test/ghsa/GHSA-qhwx-74w5-xhxq/.
  • GHSA-r4fx-v8hh-22mv — bypass of the documented timeout control via a FinalizationRegistry cleanup callback. timeout bounds only the synchronous body of run(); a cleanup callback is invoked by the garbage collector after run() has returned, so sandbox code inside it ran with no timeout accounting at all and could block the host event loop indefinitely — and unlike Promise continuations, allowAsync: false did not close it. lib/setup-sandbox.js now removes FinalizationRegistry and WeakRef from the default sandbox globals, the way timers already are; NodeVM inherits the removal, and embedders who need them for trusted code can re-expose them through the sandbox option. This restores the timeout guarantee for the default configuration; it is not a general DoS-prevention claim (see the README Hardening recommendations). See ATTACKS.md Category 42 and test/ghsa/GHSA-r4fx-v8hh-22mv/.
  • GHSA-wjwh-qqvp-g4p4 (dup: GHSA-m3pp-qgq7-gwm6) — VM sandbox escape via WebAssembly.compileStreaming / instantiateStreaming. On Node 26 both return a Promise whose [[Prototype]] chain reaches the host realm's Promise.prototype, so vm2's sandbox-side Promise overrides and resetPromiseSpecies never run on it; an attacker constructor[Symbol.species] plus p.finally() then delivered the raw host rejection into sandbox code (e.constructor.constructor("return globalThis")() → host process). lib/setup-sandbox.js now removes both streaming-compile APIs alongside the JSPI constructors (GHSA-6j2x-vhqr-qr7q); the non-streaming WebAssembly.compile / instantiate are unaffected. See ATTACKS.md Category 33 (extended) and test/ghsa/GHSA-wjwh-qqvp-g4p4/.
  • GHSA-x6m4-chr9-cg97 — host filesystem path disclosure to sandbox code via host-realm error stacks, a patch bypass of GHSA-v27g-jcqj-v8rw. v27g redacts host frames only from stacks formatted in the sandbox realm, but a host-realm Error arrives with .stack already formatted host-side — absolute paths, node:/internal/ frames, vm2's own lib/*.js, and the embedding application's source — and crossed to the sandbox verbatim; reachable with no special configuration via eval('@@@ catch'), which makes vm2's host-side transformer throw, and generally via any embedder-exposed host function or host builtin that throws. Redacted at three chokepoints, each preserving the message and clean sandbox frames: lib/bridge.js (get + getOwnPropertyDescriptor traps, gated on a severance-robust host-error brand check, covering both the data-property and the Node 22+ accessor shape of Error#stack), lib/setup-sandbox.js (sanitizeHostOwnProps, covering the GHSA-m283 rebuild path that never crosses a bridge trap), and lib/vm.js (sandbox-destined compile errors truncated pre-bridge). Information disclosure only. See ATTACKS.md Category 48 and test/ghsa/GHSA-x6m4-chr9-cg97/.

[3.11.6]

Five advisories closed. Patch release — no API changes for valid configurations.

Security fixes

  • GHSA-cfcw-xp6x-25gj — stacked-indirection bypass of the GHSA-v6mx-mf47-r5wg apply-trap peel: the peel inspected one layer of Function.prototype.{call,apply,bind} indirection, so two layers slipped the host prototype mutator past it, and a follow-up variant laundered the severance entirely host-side. Closed at two independent chokepoints — lib/bridge.js refuses to deliver host prototype mutators, and both it and handleException reject any host object whose prototype chain reaches null without passing through the sandbox Object.prototype. See ATTACKS.md Category 37 and test/ghsa/GHSA-cfcw-xp6x-25gj/.
  • GHSA-gmc2-2x9w-cgh9 — bufferAllocLimit (GHSA-6785-pvv7-mvg7) bypass via Buffer.concat(list, totalLength) and Buffer.from(arrayLike), whose host implementations reach the C++ allocator without traversing the sandbox-side allocUnsafe wrapper. lib/setup-sandbox.js now caps concat, from, and copyBytesFrom, and a fail-closed enumeration of host.Buffer's own keys turns any future uncapped allocator into an explicit error rather than a silent bypass. See ATTACKS.md Category 23 (extended) and test/ghsa/GHSA-gmc2-2x9w-cgh9/.
  • GHSA-m283-3h24-438v — host errors leaked live host references into sandbox catch blocks through four channels: Error.cause, the SuppressedError / AggregateError sub-error slots, arbitrary own properties (err.detail = process), and — beyond the reach of own-key enumeration — the carrier's own prototype chain. handleException now seals the spec-defined slots and rebuilds every host-wrapped carrier as a fresh sandbox-realm error carrying only its primitive properties, discarding the host prototype chain entirely. See ATTACKS.md Category 38 and test/ghsa/GHSA-m283-3h24-438v/.
  • GHSA-m5w8-4gq2-6f8x — sibling of GHSA-9g8x: NodeVM builtin: ['*'] still surfaced os and dns, the last two process-wide builtins. Beyond host-identity and network-topology reads, both carry writes reachable in one line of sandbox code — dns.setServers() hijacks the host's DNS resolver, os.setPriority() renices the host process. lib/builtin.js adds both to DANGEROUS_BUILTINS, covering node: spellings and dns/promises automatically. See ATTACKS.md Category 35 (extended) and test/ghsa/GHSA-m5w8-4gq2-6f8x/.
  • GHSA-v836-6xw4-9cx3 — bufferAllocLimit bypass via ArrayBuffer / SharedArrayBuffer / TypedArray / WebAssembly.Memory, which reach the same synchronous, timeout-immune V8 backing-store allocator uncapped. When a finite limit is set, lib/setup-sandbox.js wraps each constructor with a construct trap capping the ToIndex-coerced byte count, and pins prototype.constructor so the uncapped intrinsic cannot be recovered by a constructor walk. The default Infinity leaves them untouched. See ATTACKS.md Category 36 and test/ghsa/GHSA-v836-6xw4-9cx3/.

Upgrade notes

  • If you use NodeVM({ require: { builtin: ['*'] } }) and depend on os or dns, those two builtins are now denied (GHSA-m5w8-4gq2-6f8x), together with node:os, node:dns, and dns/promises. They join the process-wide class closed in 3.11.4: they expose host-process identity and network topology, and dns.setServers() / dns.setDefaultResultOrder() / os.setPriority() are outright writes to host-process state. Embedders needing a sandbox-local subset (typically os.platform(), os.EOL, os.constants) should register a controlled wrapper via require.mock or require.override.
  • If you set a finite bufferAllocLimit, the cap now also covers Buffer.concat, Buffer.from, Buffer.copyBytesFrom, and the ArrayBuffer / SharedArrayBuffer / TypedArray / WebAssembly.Memory constructors (GHSA-gmc2-2x9w-cgh9, GHSA-v836-6xw4-9cx3). Sandbox code that previously allocated past the cap through those paths now gets the same RangeError. The default bufferAllocLimit: Infinity leaves every one of them untouched, so this is a no-op unless you opted into the cap.
  • Errors thrown by embedder-exposed host functions now reach sandbox catch blocks as sandbox-realm errors rather than proxies of the host error (GHSA-m283-3h24-438v). Primitive diagnostics (message, name, stack, code, errno, syscall, path, …) are preserved and error subclasses (TypeError, RangeError, …) now satisfy instanceof correctly inside the sandbox; non-primitive properties, and anything reachable through the error's prototype chain, are gone by design.

[3.11.5]

Patch release — no API changes.

Fix

  • #566 — util.inspect of vm.run(...) results rendered as Proxy(Proxy({})) on Node 26+. Install nodejs.util.inspect.custom on host-side proxy targets so the inspect output reflects the underlying shape.
  • #567 — Array iteration methods on a vm.freeze()-d host array threw an 'isExtensible' on proxy invariant error (regression from the GHSA-grj5-jjm8-h35p species defense). Align the ReadOnly proxy target's extensibility with its trap result and skip species neutralization on the host→sandbox apply path.

[3.11.4]

Ten advisories closed. Patch release — no API changes for valid configurations.

Security fixes

  • GHSA-c4cf-2hgv-2qv6 — bridge escape via BaseHandler.set ignoring the ECMA-262 §9.5.9 Receiver argument; Object.create(hostProxy).x = v and Reflect.set(hostProxy, k, v, sandboxObj) wrote through to the host object instead of installing on the receiver, turning every embedder-exposed host object into a sandbox write channel. Receiver-gated install-on-receiver fix in lib/bridge.js mirroring ReadOnlyHandler.set. See ATTACKS.md Category 32 and test/ghsa/GHSA-c4cf-2hgv-2qv6/.
  • GHSA-m5q2-4fm3-vfqp — sandbox escape via unblocked cross-realm Symbol.for keys plus missing dangerous-symbol guards on the bridge's write traps. Two-layer structural fix: lib/setup-sandbox.js denies the entire nodejs. namespace at Symbol.for and aligns the read-side filters with the full 9-symbol cache, and lib/bridge.js extends isDangerousCrossRealmSymbol and applies it to the set/defineProperty/deleteProperty traps. See ATTACKS.md Category 8 / Category 20 (both extended) and test/ghsa/GHSA-m5q2-4fm3-vfqp/.
  • GHSA-v6mx-mf47-r5wg — host prototype mutation via apply-trap indirection. Sandbox code could reach host prototype-mutating setters (Object.prototype.__proto__, setPrototypeOf, defineProperty, __defineSetter__/__defineGetter__) through Function.prototype.{call,apply,bind} and Reflect.{apply,construct} indirection, sever a host intrinsic's prototype chain, and escape via the bridge's thisEnsureThis proto-walk fallthrough. Two-layer structural fix in lib/bridge.js (apply-trap blocklist + cache check before proto-walk). See ATTACKS.md Category 30 and test/ghsa/GHSA-v6mx-mf47-r5wg/.
  • GHSA-q3fm-4wcw-g57x — Defense Invariant #11 hardening for defaultSandboxPrepareStackTrace (second variant of GHSA-9qj6-qjgg-37qq in a different file). The sandbox stack-trace formatter accumulated frames in a sandbox-realm array and .join-ed them, so a sandbox-installed setter on Array.prototype[N] (or .join override) observed bridge-internal state — no host reference reachable today, but one enrichment away from regressing into the GHSA-9qj6 RCE shape. Fixed in lib/setup-sandbox.js by folding frames through a primitive string accumulator (no Array.prototype slot reachable) and converting makeCallSiteGetters to localReflectDefineProperty for symmetry. See ATTACKS.md Category 28 Variant B and test/ghsa/GHSA-q3fm-4wcw-g57x/.
  • GHSA-76w7-j9cq-rx2j — Promise species hijack in the localPromise swallow tail. The swallow-tail apply(globalPromisePrototypeThen, this, [...]) call inside localPromise's constructor invoked the cached host Promise.prototype.then without first calling resetPromiseSpecies(this), so a sandbox subclass overriding [Symbol.species] could redirect the downstream child constructor to a user function and capture V8's internal (resolve, reject) capability — delivering a raw host-realm error (RangeError from deep recursion + e.stack) to a sandbox collector and reaching the host Function constructor via .constructor.constructor. One-line fix in lib/setup-sandbox.js adds the missing resetPromiseSpecies(this) before the swallow-tail call, matching the pattern already used by the .then/.catch/Reflect.apply overrides. See ATTACKS.md Category 31 and test/ghsa/GHSA-76w7-j9cq-rx2j/.
  • GHSA-m4wx-m65x-ghrr — NodeVM constructor patch bypass of GHSA-8hg8-63c5-gwmx: a truthy nesting paired with anything other than a real require config object produced a NESTING_OVERRIDE-only resolver → inner NodeVM with attacker-chosen require → child_process RCE. Structural fix in lib/nodevm.js: destructure first, then reject at construction whenever nesting is truthy and requireOpts is not a non-null object or Resolver. Supersedes GHSA-8hg8-63c5-gwmx. See ATTACKS.md Category 25 and test/ghsa/GHSA-m4wx-m65x-ghrr/.
  • GHSA-6j2x-vhqr-qr7q — sandbox escape via WebAssembly JSPI (Node 24 behind --experimental-wasm-jspi, Node 26+ default). WebAssembly.promising returns Promise objects whose [[Prototype]] chain points directly at the host realm's Promise.prototype with no bridge proxy in between, so p.finally() reaches host Promise.prototype.finally, V8's SpeciesConstructor reads an attacker-controlled p.constructor getter, and the eventual host-realm rejection is dispatched through the attacker's class with no bridge wrapping — e.constructor.constructor('return process')() then evaluates in the host realm. Structural fix in lib/setup-sandbox.js: delete WebAssembly.promising and WebAssembly.Suspending at sandbox bootstrap, mirroring the existing WebAssembly.JSTag removal. Adds Defense Invariant #12 (no sandbox-visible object may have a host-realm prototype chain without bridge interposition). See ATTACKS.md Category 33 and test/ghsa/GHSA-6j2x-vhqr-qr7q/.
  • GHSA-rp36-8xq3-r6c4 — NodeVM builtin denylist bypass via process and inspector/promises. The exact-match denylist in lib/builtin.js missed two host-passthrough families: process (whose getBuiltinModule(name) reloads any core module regardless of the embedder's allow/deny configuration) and inspector/promises (whose Session().post('Runtime.evaluate', ...) evaluates attacker JS in the host realm). Structural fix promotes the check to family-prefix via isDangerousBuiltin(key), strips the node: URL prefix, and adds process to the dangerous set — enforced at both BUILTIN_MODULES source and addDefaultBuiltin. Supersedes GHSA-947f-4v7f-x2v8. Adds Defense Invariant #13. See ATTACKS.md Category 21 (extended) and test/ghsa/GHSA-rp36-8xq3-r6c4/.
  • GHSA-r9pm-gxmw-wv6p — NodeVM builtin: ['*'] wildcard exposed Node's undocumented underscored network builtins (_http_client, _http_server, the _http_* / _tls_* / _stream_* siblings), letting sandbox code make outbound HTTP requests and open listening sockets even when the documented -http/-https/-net/-tls exclusions were used — SSRF-class capability bypass (CVSS 8.6). Structural fix in lib/builtin.js: BUILTIN_MODULES filter now excludes any name starting with _, so '*' expands only to documented public builtins; explicit opt-in, mock, and override paths remain functional. See ATTACKS.md Category 34 and test/ghsa/GHSA-r9pm-gxmw-wv6p/.
  • GHSA-9g8x-92q2-p28f — NodeVM builtin allowlist surfaced four process-wide observability builtins (diagnostics_channel, async_hooks, perf_hooks, v8) that read state from the entire host process rather than the sandbox: HTTP IncomingMessage headers (incl. auth tokens) via diagnostics_channel.subscribe, embedder AsyncLocalStorage context via async_hooks.executionAsyncResource, embedder performance.mark labels via perf_hooks, and the full V8 heap via v8.getHeapSnapshot / v8.queryObjects. Fix in lib/builtin.js: extends DANGEROUS_BUILTINS with the four names, reusing the existing two-layer enforcement (BUILTIN_MODULES filter + addDefaultBuiltin rejection, family-prefix and node:-normalised via isDangerousBuiltin). mock/override escape hatches preserved. See ATTACKS.md Category 35 and test/ghsa/GHSA-9g8x-92q2-p28f/.

Upgrade notes

  • If you constructed NodeVM({ nesting: <truthy> }) without an explicit require config object, new NodeVM(...) now throws (GHSA-m4wx-m65x-ghrr). This covers every shape that previously silently produced a vm2-only resolver: omitting require entirely, or setting it to any falsy value (false/undefined/null/0/'') or any truthy non-object value (true/number/string/symbol/function); and also any truthy nesting value, not only nesting: true (1/'yes'/{}/[]/function). Either drop nesting, or pass an explicit require config object (e.g. require: { builtin: [] }) to acknowledge that vm2 will be requireable from inside the sandbox. The error message is actionable and links to the README hardening section.

[3.11.3]

Patch release — no API changes.

Security fix

  • GHSA-248r-7h7q-cr24 — async generator yield*-return thenable exception capture. Calling i.return(thenable) on an async generator delegating to a no-return inner iterator let V8's PromiseResolveThenableJob capture synchronous throws from the thenable's .then and surface them to sandbox code as iterator results — bypassing both the transformer's catch instrumentation and the globalPromise.prototype.then rejection sanitiser. Two-layer defense on %AsyncGeneratorPrototype%.next/.return/.throw in lib/setup-sandbox.js: every iterator-result promise routes value and rejection through handleException, and every thenable argument is replaced with a sandbox-realm wrapper whose .then is a fixed safeThen that sanitises sync throws and recursively re-wraps any nested thenable handed to resolve(...). When safeThen reads value.then and it is non-function, the wrapper always resolves with a {__proto__: null} shadow so V8's re-read of .then cannot observe attacker-controlled values — closing every counting/self-replacing-getter TOCTOU variant. Trade-off: identity is not preserved for non-thenable values passed to i.return(x). ATTACKS.md Category 29.

[3.11.2]

Three advisories closed. Patch release — no API changes.

Security fixes

  • GHSA-2cm2-m3w5-gp2f — Internal state reachable via computed property access on globalThis. The previous fix (GHSA-wp5r-2gw5-m7q7) tightened the transformer's identifier-rejection but left globalThis['VM2_INTERNAL_STATE_DO_NOT_USE_OR_PROGRAM_WILL_FAIL'] and every reflective probe of the global object (bracket access, Reflect.get, Object.getOwnPropertyDescriptor, Object.getOwnPropertyNames enumeration) returning the live state object — the transformer is a syntactic gate and cannot see through dynamic property keys. Structural fix: the bootstrap script (vm.js's setupSandboxScript source) now declares let VM2_INTERNAL_STATE_DO_NOT_USE_OR_PROGRAM_WILL_FAIL at the script's top level, which lands the binding in the context's [[GlobalLexicalEnvironment]] — reachable as a bare identifier from every script (so transformer-emitted catch handlers still resolve), but absent from globalThis's own-property table (so every computed-key probe returns undefined). The defineProperty install in setup-sandbox.js is removed entirely; the bootstrap IIFE assigns into the outer let instead. Supersedes GHSA-wp5r-2gw5-m7q7's identifier-only mitigation by closing the entire computed-key class. ATTACKS.md Category 27.
  • GHSA-9vg3-4rfj-wgcm — Sandbox breakout via null-proto throw / handleException. The post-GHSA-mpf8 hardening switched handleException and globalPromise.prototype.then onFulfilled to wrap caught/resolved values with bridge.from() for "symmetry". from() builds a sandbox-side proxy whose target the bridge treats as host-realm; calling it on a sandbox-realm null-proto value ({__proto__: null} thrown or Promise.resolve-d by sandbox JS) produced a proxy whose set trap unwrapped sandbox proxies of host references (e.g. Buffer.prototype.inspect) back to their raw host originals and stored them on the underlying sandbox object — readable via the original sandbox reference and pivot to host Function constructor → RCE. Three callsites in lib/setup-sandbox.js reverted to ensureThis() semantics; the host-Promise rejection sanitizer composes from() outside handleException so the GHSA-mpf8 invariant (host null-proto rejection values must reach sandbox callbacks bridge-wrapped) is preserved. ATTACKS.md Category 26.
  • GHSA-9qj6-qjgg-37qq — sandbox breakout via the species-defense helper neutralizeArraySpeciesBatch. The helper appended saved-state records to a fresh [] literal that — being allocated by the sandbox-side bridge closure — inherited sandbox Array.prototype. A sandbox-installed setter on Array.prototype[N] therefore captured the next saved[saved.length] = c write and exposed c.arr (a host-realm proxy) directly to attacker code, leading to host Function extraction and RCE. Fixed in lib/bridge.js by writing every saved-state entry through thisReflectDefineProperty so the appended slot is an own data property and no Array.prototype[N] setter is ever invoked while the bridge holds raw saved state. ATTACKS.md gains a new Defense Invariant ("Bridge-internal containers must not invoke sandbox code") codifying the cross-cutting principle.

[3.11.1]

Single advisory closed plus prominent documentation of an existing escape hatch. Patch release — no API changes for valid configurations.

Security fix

  • 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.

Documentation

  • 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.
  • JSDoc on the nesting option (lib/nodevm.js) upgraded to spell out the escape-hatch semantics and the GHSA-8hg8 contradictory-pair rejection.
  • ATTACKS.md gains Category 25 documenting the configuration trap and a matching row in the "How The Bridge Defends" table.

Upgrade notes

  • 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.
  • 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).

What this fix does NOT close

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.

[3.11.0]

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 section.

Upgrade notes

  • Custom fs adapters with require.root must implement realpathSync (or realpath() on a fully custom FileSystem class). Without it, new NodeVM({require: {root, fs: customAdapter}}) now throws a VMError at construction, citing GHSA-cp6g-6699-wx9c. The eager probe converts what was previously silent deny-by-default at every later require() into a single, clearly-labelled construction-time error. Default fs users are unaffected — DefaultFileSystem and VMFileSystem ship realpath() out of the box.
  • Embedders running untrusted async code should install a host-side unhandledRejection handler. The GHSA-hw58 fix closes synchronous executor throws but cannot reach async-function / async-generator / await using rejection paths (V8 creates rejection promises via the realm's intrinsic Promise). See README's Hardening recommendations and ATTACKS.md Category 22.
  • Embedders running untrusted code in memory-constrained environments should opt into a finite bufferAllocLimit (e.g. 32 * 1024 * 1024) as part of layered DoS defense. Default remains Infinity for backwards compatibility.

Security fixes

  • GHSA-grj5-jjm8-h35p — Array species self-return sandbox escape. Bridge apply and construct traps now neutralise host-array constructor and Symbol.species before every host call (and restore in a finally block). Direct write, Object.assign, non-configurable defineProperty, and prototype-level constructor variants all blocked.
  • GHSA-v37h-5mfm-c47c — Handler reconstruction via util.inspect leak. Three-layer defense: closure-scoped construction token, getHandlerObject WeakMap guard, and .constructor sentinel rebind on every handler-class prototype (including BufferHandler).
  • GHSA-qcp4-v2jj-fjx8 — Trap method on leaked handler with forged target. New handlerToTarget WeakMap pairs every handler with its canonical proxy target at construction; validateHandlerTarget(this, target) at the entry of every trap method rejects forged-this and forged-target invocations with VMError(OPNA).
  • GHSA-47x8-96vw-5wg6 — Cross-realm symbol extraction from host objects. Two-layer defense: dangerous cross-realm symbols (nodejs.util.inspect.custom, nodejs.rejection, nodejs.util.promisify.custom) are filtered at the bridge boundary; structural identity collapse pre-populates the bridge identity caches for every built-in intrinsic prototype + constructor pair so prototype walks land on sandbox primordials.
  • GHSA-55hx-c926-fr95 — Promise structural-leak / SuppressedError / AggregateError sanitisation. handleException now recurses into AggregateError.errors[] (in addition to SuppressedError.error/.suppressed); the bridge-level apply-trap recognises calls to host Promise.prototype.{then,catch,finally} by cached identity and pipes every sandbox callback through the same sanitiser.
  • GHSA-vwrp-x96c-mhwq — Host intrinsic prototype pollution via bridge write traps. Closure-scoped protectedHostObjects WeakMap is populated at bridge init with every entry in globalsList + errorsList (including AggregateError) plus each prototype's .constructor. The four write traps (set, defineProperty, deleteProperty, preventExtensions) reject with VMError(OPNA) when targeting a protected intrinsic.
  • GHSA-947f-4v7f-x2v8 — NodeVM builtin allowlist bypass via host-passthrough builtins. DANGEROUS_BUILTINS = ['module', 'worker_threads', 'cluster', 'vm', 'repl', 'inspector', 'trace_events', 'wasi']. Two-layer enforcement: filtered from BUILTIN_MODULES (closes '*' wildcard expansion) AND rejected in addDefaultBuiltin (closes explicit-name + makeBuiltins(...) paths). mock / override escape hatches preserved.
  • GHSA-hw58-p9xv-2mjh — Promise executor unhandled rejection host-process DoS. localPromise constructor wraps the user-supplied executor in try/catch (synchronous throws routed through handleException and rejected as sandbox-realm values) and attaches a benign swallow tail to every sandbox-constructed Promise so the host's unhandledRejection event never fires. Known residual: async function / async generator / await using paths bypass the executor wrap (V8 creates rejection promises via the realm's intrinsic Promise). Documented in ATTACKS.md Category 22 — embedders should install a host-side unhandledRejection handler. See README's Hardening recommendations.
  • GHSA-6785-pvv7-mvg7 — Unbounded Buffer.alloc(N) host-heap DoS. New bufferAllocLimit option (default Infinity — fully backwards-compatible) caps single allocations on Buffer.alloc, Buffer.allocUnsafe, Buffer.allocUnsafeSlow, deprecated Buffer(N), and new Buffer(N). Embedders running untrusted code should opt into a finite cap (e.g. 32 * 1024 * 1024) as part of layered DoS defense, the same way they opt into timeout. Forwarded from NodeVM to its parent VM via super(options).
  • GHSA-mpf8-4hx2-7cjg — Host Promise .then(onFulfilled) / sanitiser-callback null-proto unwrapping. Sandbox-side globalPromise.prototype.then onFulfilled and the bridge-level host-Promise sanitiser now use from() (always wraps) instead of ensureThis() (proto-fallthrough on null-proto host objects). handleException also switched to from() for symmetry on the rejection path.
  • GHSA-v27g-jcqj-v8rw — CallSite host-frame information disclosure via prepareStackTrace. applyCallSiteGetters redacts every metadata getter (getFileName, getLineNumber, getColumnNumber, getFunctionName, getMethodName, getTypeName, etc.) for host frames; getEvalOrigin redacts unconditionally because its return string can embed a host path. Error.prepareStackTrace is initialised to defaultSandboxPrepareStackTrace at sandbox bootstrap so V8 never falls through to Node's host-side formatter (which throws on Symbol-named errors and emits absolute host paths).
  • GHSA-wp5r-2gw5-m7q7 — Transformer fast-path bypass via with/INTERNAL_STATE_NAME/unicode-escape identifier. Fast-path bailout now triggers AST instrumentation for any source containing catch, import, async, with, the INTERNAL_STATE_NAME substring, or any \u escape (identifiers like VM2_INTERNAL_STATE_… are valid JS and would slip past a literal-string check).
  • GHSA-cp6g-6699-wx9c — NodeVM require.root symlink bypass (path-check / use TOCTOU). Lexical prefix check on path.resolve()-resolved candidates was bypassed by symlinks inside the allowed root pointing outside it (Node's native require() follows symlinks; CWE-59). Especially severe with pnpm / npm-workspaces / npm link layouts where every node_modules entry is a symlink by design. Fixed by canonicalising candidate paths via fs.realpathSync before the prefix check and canonicalising rootPaths at construction time. DefaultFileSystem and VMFileSystem gain a realpath() method; if realpath throws at runtime (missing file, broken link) the check denies by default. An eager FileSystem-contract probe at new NodeVM(...) time throws VMError immediately if require.root is set and the adapter cannot dereference symlinks (missing realpath() method, or VMFileSystem wrapping an fs without realpathSync) — see Upgrade notes. ATTACKS.md Category 24.

New options

  • bufferAllocLimit (VM, NodeVM) — non-negative number or Infinity. Caps individual Buffer.alloc family requests from inside the sandbox. Default: Infinity. See README's "Hardening recommendations".

Other security improvements

  • trace_events host-process abort DoS — surfaced during pre-tag red-team. trace_events.createTracing({categories: [Proxy<Array>]}) triggered a C++ IsArray() assertion failure that aborted the host process. Added to DANGEROUS_BUILTINS.
  • wasi added to the denylist — experimental syscall surface (filesystem preopens, host clock/random, network) too broad for default '*' exposure.

Documentation

  • New README "Hardening recommendations" section covering bufferAllocLimit usage, unhandledRejection handler shape (mitigates async-fn residual above), --max-old-space-size complement, and '*' allowlist semantics.
  • ATTACKS.md updated for Categories 4, 9, 12, 19, 20, 21, 22, 24 to reflect the deployed defenses, the v27g getEvalOrigin/Path A hardening, the qcp4 validateHandlerTarget, the wp5r unicode-escape hardening, the GHSA-hw58 async-fn known residual, and the new cp6g symlink-bypass mitigation.

Test infrastructure

  • scripts/legacy-test-runner.js now supports this.skip() for runtime-conditional skipping and Promise-returning async tests (length-0 async function () {}).