VYPR
Medium severity4.6GHSA Advisory· Published May 29, 2026· Updated May 29, 2026

NodeVM observability builtins leak host process and HTTP request data

CVE-2026-47141

Description

Summary

NodeVM exposes some process-wide observability builtins when they are allowed through require.builtin.

The following builtins are not blocked by the dangerous builtin denylist:

diagnostics_channel
async_hooks
perf_hooks

These modules are process-wide, not sandbox-local. Sandboxed code can use them to observe host application data across the vm2 boundary.

Note: It is a host data exposure issue. The impact depends on whether the host application allows these builtins and uses HTTP, async request context, diagnostics channels, or performance marks in the same process.

Details

Non-denied builtins are exposed to the sandbox through lib/builtin.js:

builtins.set(key, special ? special : vm => vm.readonly(hostRequire(key)));

diagnostics_channel, async_hooks, and perf_hooks are not denied. These modules expose host process state rather than sandbox-local state.

Confirmed examples:

  1. diagnostics_channel lets sandboxed code subscribe to Node.js HTTP diagnostic channels such as http.server.request.start. The sandbox receives host HTTP request objects and can read headers such as Authorization or session tokens.
  2. async_hooks.executionAsyncResource() lets sandboxed code read the current host AsyncResource. If the host stores request/user data on that resource, the sandbox can read it.
  3. perf_hooks.performance.getEntriesByType('mark') lets sandboxed code read host performance timeline entries.

PoC

Run from the vm2 repository root:

node poc/observability-builtins-info-leak.js

observability-builtins-info-leak.js

The PoC uses only the specific builtin being tested in each section.

It confirms:

diagnostics_channel: sandbox reads host HTTP request headers
async_hooks: sandbox reads host AsyncResource data
perf_hooks: sandbox reads host performance mark names

Example impact from the PoC:

authorization: Bearer HOST_HTTP_SECRET_...
x-session-token: HOST_HTTP_SECRET_...

These values are sent to a host HTTP server, but the sandbox reads them through diagnostics_channel.

Impact

An attacker who can run untrusted JavaScript inside NodeVM with affected builtin settings can observe data from the host process.

In a real application, this may expose HTTP request headers, authorization tokens, session tokens, request context values, user identifiers, or other sensitive diagnostics data from the host application or from other users.

Suggested fix

Treat process-wide observability modules as dangerous builtins for untrusted sandboxes.

At minimum, consider blocking:

diagnostics_channel
async_hooks
perf_hooks

These modules are not sandbox-local and can expose host process state across the vm2 boundary.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

vm2's NodeVM exposes process-wide observability builtins (diagnostics_channel, async_hooks, perf_hooks) allowing sandboxed code to leak host application data.

Vulnerability

NodeVM in vm2 prior to version 3.11.4 does not block the builtins diagnostics_channel, async_hooks, and perf_hooks in its dangerous denylist [1][2][3][4]. These modules are process-wide and expose host application state rather than sandbox-local state. When any of these builtins are allowed through require.builtin, sandboxed code can access host data across the sandbox boundary [3][4].

Exploitation

An attacker who can execute untrusted JavaScript inside a NodeVM instance with these builtins allowed can perform the following [3][4]:

  • Use diagnostics_channel to subscribe to host Node.js HTTP diagnostic channels (e.g., http.server.request.start) and read HTTP request objects, including headers like Authorization or session tokens. [3][4]
  • Use async_hooks.executionAsyncResource() to read the current host AsyncResource, potentially exposing request/user data stored on it. [3][4]
  • Use perf_hooks.performance.getEntriesByType('mark') to read host performance timeline entries, which may contain sensitive names. [3][4]

No special privileges or user interaction beyond the ability to run code in the sandbox are required [3].

Impact

Successful exploitation leads to unauthorized information disclosure [3][4]. The attacker can leak host application data such as HTTP request headers (e.g., Authorization: Bearer HOST_HTTP_SECRET_..., x-session-token), async resource context data, and performance mark names [3][4]. The exact impact depends on whether the host application uses these builtins and processes sensitive data in the same process [3][4]. This is a host data exposure issue, not remote code execution.

Mitigation

The vulnerability is fixed in vm2 version 3.11.4, released on 2026-05-29 [2]. The fix adds diagnostics_channel, async_hooks, perf_hooks, and v8 to the DANGEROUS_BUILTINS set, denying them by default [1][2]. As a workaround, ensure that these builtins are not included in the require.builtin allowlist if they are not needed [3][4].

AI Insight generated on May 29, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2

Patches

1
e1c48fce0518

fix(GHSA-9g8x-92q2-p28f): deny process-wide observability builtins in NodeVM

https://github.com/patriksimek/vm2Patrik SimekMay 17, 2026via ghsa
4 files changed · +383 4
  • CHANGELOG.md+2 1 modified
    @@ -2,7 +2,7 @@
     
     ## [3.11.4]
     
    -Nine advisories closed. Patch release — no API changes for valid configurations.
    +Ten advisories closed. Patch release — no API changes for valid configurations.
     
     ### Security fixes
     
    @@ -15,6 +15,7 @@ Nine advisories closed. Patch release — no API changes for valid configuration
     - **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
     
    
  • docs/ATTACKS.md+93 2 modified
    @@ -1607,7 +1607,7 @@ The user's mental model of `['*', '-child_process']` is "every builtin except `c
     
     Three-layer denylist enforcement in `lib/builtin.js` (restores **[Invariant 13 — The NodeVM builtin allowlist is a closed system](#defense-invariants)**):
     
    -1. **`DANGEROUS_BUILTINS` Set** at module load — `['module', 'worker_threads', 'cluster', 'vm', 'repl', 'inspector', 'process', 'trace_events', 'wasi']`.
    +1. **`DANGEROUS_BUILTINS` Set** at module load — `['module', 'worker_threads', 'cluster', 'vm', 'repl', 'inspector', 'process', 'trace_events', 'wasi', 'diagnostics_channel', 'async_hooks', 'perf_hooks', 'v8']`. The last four were added by [Category 35](#attack-category-35-nodevm-process-wide-observability-builtins-host-data-info-leak) for the process-wide observability info-leak class; they share the deny-by-default enforcement but a different threat model (data exposure, not code execution).
     2. **Family-prefix check** via `isDangerousBuiltin(key)` — any `<family>/...` whose family is in the denylist is also blocked (e.g. `inspector/promises`, future `inspector/foo`, hypothetical `process/foo`, `module/foo`). The check also strips the optional `node:` URL-style prefix so `node:process` and `node:inspector/promises` are caught.
     3. **Filter from `BUILTIN_MODULES`** — closes the `'*'` wildcard expansion path. `'*'` will never auto-allow these names regardless of the user's exclusion list.
     4. **Reject in `addDefaultBuiltin`** — closes the explicit-allowlist path (`builtin: ['module']`, `builtin: ['process']`, `builtin: ['inspector/promises']`) and the lower-level `makeBuiltins([...])` API used by custom resolvers. The `SPECIAL_MODULES` escape hatch is preserved: a future safe wrapper (e.g. a `module` shim that exposes only `builtinModules` metadata) can be registered there if a real consumer needs it.
    @@ -1637,7 +1637,7 @@ The fix does not affect the `mocks` / `overrides` escape hatches — users who g
     
     ### Considered Attack Surfaces
     
    -- **`async_hooks`** exposes context tracing but not host-code-loading primitives. Allowed under `'*'`.
    +- **`async_hooks`, `diagnostics_channel`, `perf_hooks`, `v8`** are now denied as process-wide observability primitives — see [Category 30](#attack-category-30-nodevm-process-wide-observability-builtins-host-data-info-leak). They expose host-process state rather than host-code-loading primitives, but are functionally identical from the embedder's perspective: any allowlist that includes them leaks per-request user data, auth tokens, and heap contents into the sandbox.
     - **`child_process`** is NOT on the auto-denylist because users may legitimately want it for trusted scripts (e.g., dev tooling running known scripts in vm2 for hot-reload isolation). For untrusted code, `child_process` is a full-host-RCE primitive — embedders MUST exclude it explicitly (`['*', '-child_process']`) or, better, use an explicit allowlist of just the modules they need. The README's "Hardening recommendations" section calls this out.
     - **`fs`** is allowed under `'*'` because file-system access can be a legitimate sandbox capability for many use cases (e.g., user-script template engines reading templates). Users who want filesystem isolation use `VMFileSystem` or exclude `fs` explicitly. Same caveat as `child_process` — `'*'` is not sandbox-safe for untrusted code.
     - **`dgram`, `net`, `http`, `https`, `dns`** are network-IO builtins, allowed under `'*'`. Any of them give untrusted code outbound network access from the host. Embedders should explicitly exclude or allowlist.
    @@ -2891,6 +2891,96 @@ None. This fix complements [Category 21](#attack-category-21-nodevm-builtin-allo
     
     ---
     
    +## Attack Category 35: NodeVM Process-Wide Observability Builtins (Host-Data Info Leak)
    +
    +### Description
    +
    +NodeVM's `require.builtin` allowlist defends sandbox code from reaching dangerous Node modules. [Category 21](#attack-category-21-nodevm-builtin-allowlist-bypass-via-host-passthrough-builtins) denied the host-code-loading primitives (`module`, `worker_threads`, `cluster`, `vm`, `repl`, `inspector`, `process`, `trace_events`, `wasi`). A second class of dangerous builtins exists with a different threat model: **process-wide observability modules** whose primary capability is reading state of the entire host Node process, not loading or executing code.
    +
    +When such a builtin is reachable from the sandbox (via the `'*'` wildcard or an explicit allowlist), the sandbox can subscribe to or read host process state directly — no RCE chain needed. The data the embedder routes through these APIs in the same process (HTTP requests, async-context user IDs, performance marks, V8 heap) is by definition host data; reaching the host module *is* the escape.
    +
    +CWE-668 (Exposure of Resource to Wrong Sphere). Info-leak class, not RCE class.
    +
    +### Attack Flow
    +
    +Each builtin gives a one-liner exfiltration primitive. Once the sandbox holds a readonly proxy over the host module, the proxy's `apply` trap forwards every method call back to the host realm:
    +
    +- **`diagnostics_channel`** — `dc.channel('http.server.request.start').subscribe(cb)`. The sandbox callback receives raw host `IncomingMessage` objects for every HTTP request the embedder serves, with full `Authorization`, `Cookie`, `x-session-token` headers intact.
    +- **`async_hooks`** — `async_hooks.executionAsyncResource()` returns the current host `AsyncResource`. Embedders that use `AsyncLocalStorage` for per-request user/auth context (extremely common pattern: `express`, `fastify`, `next.js`) pin that state on the resource, and the sandbox reads it directly.
    +- **`perf_hooks`** — `perf_hooks.performance.getEntriesByType('mark')` reads every host-side `performance.mark(name)`. Production code routinely embeds request IDs, user IDs, route paths, or partial query strings into mark names for observability dashboards.
    +- **`v8`** — `v8.getHeapSnapshot()` returns a Readable stream of the entire host V8 heap (every string, every Buffer, every closure capture). `v8.writeHeapSnapshot(path)` writes the same to an arbitrary host filesystem path. `v8.queryObjects(Ctor)` (Node 20+) returns every host-realm instance of a constructor.
    +
    +### Canonical Example
    +
    +```javascript
    +// (advisory GHSA-9g8x-92q2-p28f)
    +const vm = new NodeVM({ require: { builtin: ['*'], external: false } });
    +vm.run(`
    +  const dc = require('diagnostics_channel');
    +  const stolen = [];
    +  dc.channel('http.server.request.start').subscribe((req) => {
    +    stolen.push({
    +      url: req.url,
    +      authorization: req.headers.authorization,
    +      session: req.headers['x-session-token'],
    +    });
    +  });
    +  // ... wait for host HTTP traffic. Headers are read from inside the sandbox.
    +`, 'poc.js');
    +```
    +
    +Equivalent one-liners for the other three:
    +
    +```javascript
    +require('async_hooks').executionAsyncResource(); // -> host AsyncResource
    +require('perf_hooks').performance.getEntriesByType('mark'); // -> host marks
    +require('v8').writeHeapSnapshot('/tmp/host-heap.json'); // -> entire host heap on disk
    +```
    +
    +### Why It Works
    +
    +The vm2 boundary is built around the assumption that "the sandbox observes its own realm, not the host's". Most Node builtins satisfy this implicitly: `path.join(...)`, `crypto.randomBytes(...)`, `url.parse(...)` all operate on inputs the sandbox passes in and return values the sandbox owns. The bridge's `ReadOnlyHandler` makes those builtins safe via uniform proxy semantics.
    +
    +Process-wide observability builtins break the assumption because the data they surface *is* host data by spec — `executionAsyncResource()` returns "the resource currently executing" measured against the host's call stack, not the sandbox's. Wrapping the module in a proxy does not localize the data source. The bridge cannot usefully sanitize the values because they're real host objects (IncomingMessage, AsyncResource), and stripping them to primitives would defeat the embedder's reason for ever exposing the module in the first place.
    +
    +The four builtins in scope all share this property: they observe a process resource (HTTP request hook, async context, perf timeline, V8 heap). Mitigation must therefore be "deny by default", not "proxy more carefully".
    +
    +### Mitigation
    +
    +Extend `DANGEROUS_BUILTINS` in `lib/builtin.js` with the four observability names. Reuses the same enforcement established by Category 21 (now four-layer after the `isDangerousBuiltin` family-prefix promotion):
    +
    +1. **Filtered out of `BUILTIN_MODULES`** — closes the `'*'` wildcard expansion path. `builtin: ['*']` and `builtin: ['*', '-fs']` no longer auto-allow these names.
    +2. **Rejected in `addDefaultBuiltin`** via `isDangerousBuiltin(key)` — closes the explicit-allowlist path (`builtin: ['perf_hooks']`), the object-map form (`builtin: { v8: true }`), and the lower-level `makeBuiltins(['async_hooks'])` API used by custom resolvers.
    +3. **Family-prefix check** — any `<family>/...` whose family is in the denylist is also blocked (e.g. hypothetical `perf_hooks/foo`).
    +4. **`node:` prefix stripped before lookup** — `require('node:diagnostics_channel')` resolves identically to the bare name and is blocked by the same denial.
    +
    +The `SPECIAL_MODULES`, `mocks`, and `overrides` escape hatches are preserved: an embedder who genuinely needs sandbox-local timing or async context can register a controlled wrapper under the same name (e.g., a `perf_hooks` shim that only exposes a sandbox-local clock). The denylist only rejects the *default host-passthrough loader*.
    +
    +`v8` was added during this fix beyond the originally-named three. The class is "process-wide observability modules"; `v8.writeHeapSnapshot(path)` is strictly worse than `perf_hooks` against the same invariant (writes a full heap dump to an arbitrary host filesystem path), so excluding it would leave a wide bypass of the same class.
    +
    +The fix restores **[Defense Invariant #13](#defense-invariants)** at a different layer — the NodeVM builtin allowlist is a closed system, regardless of whether the threat is code execution or data exposure. The bridge invariant still holds for these modules; the deny-list ensures the bridge is never asked to wrap them in the first place.
    +
    +### Detection Rules
    +
    +- **`builtin: ['*']` or `builtin: ['*', '-X']`** in NodeVM config — historically auto-allowed `diagnostics_channel`, `async_hooks`, `perf_hooks`, `v8`. Now filtered. Same caveat as Category 21: `'*'` still allows `fs`, `child_process` (if not excluded), `net`, `http`, `dns` — not a sandbox-safe default for untrusted code.
    +- **`require('diagnostics_channel').channel(...).subscribe(...)`** — host HTTP/DB/IPC observability subscription.
    +- **`require('async_hooks').executionAsyncResource()` / `.createHook({...}).enable()`** — host async context inspection.
    +- **`require('perf_hooks').performance.getEntriesByType('mark' | 'measure' | 'resource')`** — host performance timeline read.
    +- **`require('v8').getHeapSnapshot()` / `.writeHeapSnapshot(path)`** — full host heap exfiltration to memory or disk.
    +- **`require('v8').queryObjects(Ctor)`** (Node 20+) — enumeration of host-realm instances of a constructor.
    +- **Sandbox code that subscribes to channels named `http.server.request.*`, `http.client.request.*`, `dns.lookup.*`, `net.client.socket.*`** — these are the canonical diagnostic channels used by Node core and request-tracking libraries.
    +
    +### Considered Attack Surfaces
    +
    +- **`os`** exposes hostname, network interfaces, user info. The data is host environment, not per-request, and is generally considered configuration metadata rather than tenant data. Allowed under `'*'` for consistency with `process.env` exposure expectations. Embedders who consider hostname/`userInfo()` sensitive should exclude `os` explicitly.
    +- **`dns`** can resolve internal hostnames and exfiltrate via DNS lookup. Network-IO class, same as `http`/`net`. Not in this denylist — embedders who care about network isolation must allowlist explicitly. Documented in Category 21's "Considered Attack Surfaces".
    +- **`zlib`, `crypto`, `string_decoder`, `buffer`** — sandbox-local data transforms, no host-state observability. Safe under default proxy semantics.
    +- **`process`** — already denied via Category 21 (after GHSA-rp36-8xq3-r6c4 extended `DANGEROUS_BUILTINS`). The sandbox global `process` is a curated stub defined in `lib/setup-node-sandbox.js`.
    +- **`worker_threads.parentPort` and `worker_threads.workerData`** — would expose host worker IPC channel and initial data. Already denied by Category 21 (entire `worker_threads` module is denied; this category is a different threat model on top, not a subset).
    +- **`http`, `https`, `http2`, `net`, `tls`, `dgram`** — network-IO modules. These do *not* observe existing host state; they originate new connections. Different threat model (outbound network from host) — covered in Category 21's "Considered Attack Surfaces" and Category 34 (underscored siblings). Embedders who want network isolation must exclude or replace them.
    +
    +---
    +
     ## Considered Attack Surfaces
     
     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.
    @@ -3021,6 +3111,7 @@ The most dangerous attacks combine multiple categories. Each pattern references
     | Bridge `set` trap ignores spec `Receiver` (GHSA-c4cf-2hgv-2qv6) | `BaseHandler.set` gates host-write forwarding on `receiver === mappingOtherToThis.get(object)`; non-canonical receivers (inherited-receiver writes via `Object.create(proxy)`, forged-receiver `Reflect.set` calls, `Object.assign(child, src)` loops) install on `receiver` via `Reflect.defineProperty`, mirroring `ReadOnlyHandler.set` |
     | NodeVM builtin denylist bypass via `process` / `inspector/promises` (GHSA-rp36-8xq3-r6c4) | `DANGEROUS_BUILTINS` extended to include `process`; matching promoted to family-prefix via `isDangerousBuiltin(key)` so subpath builtins (`inspector/promises`, future `inspector/*`, `process/*`, `module/*`) share fate with their canonical name. `node:` URL prefix stripped before lookup. Enforced at both `BUILTIN_MODULES` source and `addDefaultBuiltin`. Supersedes the GHSA-947f-4v7f-x2v8 exact-match mitigation. |
     | NodeVM wildcard exposes underscored network builtins (GHSA-r9pm-gxmw-wv6p) | `BUILTIN_MODULES` filter in `lib/builtin.js` now excludes any name starting with `_`; `'*'` no longer expands to `_http_client`/`_http_server`/`_tls_wrap`/`_stream_*` etc. Explicit opt-in (`builtin: ['_http_client']`) and `mock`/`override` paths still work via `addDefaultBuiltin`. |
    +| NodeVM process-wide observability builtins (GHSA-9g8x-92q2-p28f) | `DANGEROUS_BUILTINS` denylist extended with `diagnostics_channel`, `async_hooks`, `perf_hooks`, `v8`; filtered out of `BUILTIN_MODULES` (closes `'*'` wildcard) and rejected in `addDefaultBuiltin` via `isDangerousBuiltin` (closes explicit allowlist and `makeBuiltins([...])`). `node:` prefix normalized and family-prefix subpath matching applied. `mocks`/`overrides` escape hatch preserved for sandbox-local replacements |
     
     ### Key Security Invariant: Promise Species Resolution Timing
     
    
  • lib/builtin.js+37 1 modified
    @@ -99,7 +99,43 @@ const DANGEROUS_BUILTINS = new Set([
     	// `preopens: {}` exposes the host CWD when sandbox code constructs
     	// a WASI module. Embedders who genuinely need WASI can register a
     	// controlled wrapper via `mock`/`override`.
    -	'wasi'
    +	'wasi',
    +	// SECURITY (GHSA-9g8x-92q2-p28f): Process-wide observability builtins.
    +	// Unlike most Node builtins, these expose state of the *entire host
    +	// process* rather than sandbox-local state -- the vm2 boundary cannot
    +	// usefully contain them because the data they surface (HTTP requests,
    +	// async-context, perf marks, heap contents) belongs to the embedder.
    +	// Even a readonly proxy that forwards every call to the host module is
    +	// a working host-data exfiltration primitive:
    +	//
    +	//   - diagnostics_channel : `dc.channel('http.server.request.start').subscribe(cb)`
    +	//                           hands the sandbox raw host IncomingMessage
    +	//                           objects -- including Authorization /
    +	//                           session-token headers -- for every request the
    +	//                           embedder receives.
    +	//   - async_hooks         : `executionAsyncResource()` returns the host's
    +	//                           current AsyncResource; embedders routinely
    +	//                           pin per-request user/auth state on it via
    +	//                           AsyncLocalStorage.
    +	//   - perf_hooks          : `performance.getEntriesByType('mark')` reads
    +	//                           every host-side `performance.mark(name)`,
    +	//                           which embedders often label with request IDs,
    +	//                           user IDs, or query strings.
    +	//   - v8                  : `v8.getHeapSnapshot()` / `v8.writeHeapSnapshot()`
    +	//                           serialize the *entire* host V8 heap (every
    +	//                           string, every Buffer, every closure capture)
    +	//                           and `v8.queryObjects(Ctor)` (Node 20+) returns
    +	//                           every host-realm instance of a constructor.
    +	//                           Strictly worse than perf_hooks for the same
    +	//                           reason -- host process state, not sandbox state.
    +	//
    +	// Embedders who genuinely need a sandbox-local replacement can register a
    +	// controlled wrapper under the same name via `mock` / `override`; the
    +	// denylist only rejects the default host-passthrough loader.
    +	'diagnostics_channel',
    +	'async_hooks',
    +	'perf_hooks',
    +	'v8'
     ]);
     
     // SECURITY (GHSA-rp36-8xq3-r6c4): Family-prefix denylist check. `inspector` and
    
  • test/ghsa/GHSA-9g8x-92q2-p28f/repro.js+251 0 added
    @@ -0,0 +1,251 @@
    +/**
    + * GHSA-9g8x-92q2-p28f -- NodeVM process-wide observability builtins leak host state
    + *
    + *
    + * ## Vulnerability
    + * NodeVM allowed `require('diagnostics_channel')`, `require('async_hooks')`, and
    + * `require('perf_hooks')` (and -- found during this fix -- `require('v8')`) to
    + * resolve to the host module under the `builtin: ['*']` wildcard and any
    + * explicit allowlist that named them. Unlike most Node builtins, these surface
    + * state of the *entire host process*, not sandbox-local state:
    + *
    + *   - `diagnostics_channel.channel('http.server.request.start').subscribe(cb)`
    + *     hands the sandbox raw host IncomingMessage objects -- Authorization /
    + *     session-token headers included -- for every request the embedder receives.
    + *   - `async_hooks.executionAsyncResource()` reveals the host's current
    + *     AsyncResource; embedders routinely pin per-request user/auth state on it.
    + *   - `perf_hooks.performance.getEntriesByType('mark')` reads every host
    + *     `performance.mark(name)` value (often request/user IDs).
    + *   - `v8.getHeapSnapshot()` / `v8.writeHeapSnapshot()` / `v8.queryObjects(Ctor)`
    + *     enumerate the entire host V8 heap.
    + *
    + * The bridge cannot usefully proxy these away -- the data is host data by
    + * definition. Reaching the host module at all is the escape.
    + *
    + * ## Fix
    + * Treat process-wide observability builtins as `DANGEROUS_BUILTINS` in
    + * lib/builtin.js. Same two-layer enforcement as GHSA-947f-4v7f-x2v8 / -r9pm:
    + * filtered out of `BUILTIN_MODULES` (closes `'*'` wildcard) and rejected inside
    + * `addDefaultBuiltin` (closes explicit names, object-map form, low-level
    + * `makeBuiltins(['name'])`). `node:` prefix is normalized before lookup.
    + * `SPECIAL_MODULES`, `mocks`, and `overrides` escape hatches are preserved so
    + * embedders can register a sandbox-local wrapper under the same name.
    + *
    + * Invariant: "Any Node builtin that exposes host-process state rather than
    + * sandbox-local state is not reachable from sandbox `require()` under the
    + * default loader." Same structural test as the dangerous-builtins denylist:
    + * regardless of how the embedder phrases the allowlist, the sandbox cannot
    + * obtain a host-passthrough proxy for these modules.
    + */
    +
    +'use strict';
    +
    +const assert = require('assert');
    +const {NodeVM} = require('../../../lib/main.js');
    +const {makeBuiltins} = require('../../../lib/builtin.js');
    +
    +function expectBuiltinBlocked(name, requireOpts, sandboxCode) {
    +	const vm = new NodeVM({require: Object.assign({external: false}, requireOpts)});
    +	let escaped = null;
    +	let thrown = null;
    +	try {
    +		escaped = vm.run(sandboxCode, 'poc.js');
    +	} catch (e) {
    +		thrown = e;
    +	}
    +	assert.ok(
    +		thrown || escaped === 'BLOCKED',
    +		`[${name}] expected denial, got: ${typeof escaped === 'string' ? escaped.slice(0, 200) : escaped}`
    +	);
    +}
    +
    +const OBSERVABILITY_BUILTINS = ['diagnostics_channel', 'async_hooks', 'perf_hooks', 'v8'];
    +
    +describe('GHSA-9g8x-92q2-p28f -- process-wide observability builtins are denied', () => {
    +	for (const name of OBSERVABILITY_BUILTINS) {
    +		describe(name, () => {
    +			it("blocked under ['*']", () => {
    +				expectBuiltinBlocked(
    +					`${name}-wildcard`,
    +					{builtin: ['*']},
    +					`
    +					try {
    +						const m = require('${name}');
    +						module.exports = m ? 'ESCAPED' : 'BLOCKED';
    +					} catch (e) { module.exports = 'BLOCKED'; }
    +				`
    +				);
    +			});
    +
    +			it("blocked under ['*', '-fs'] wildcard-with-exclusion", () => {
    +				expectBuiltinBlocked(
    +					`${name}-wildcard-exclusion`,
    +					{builtin: ['*', '-fs']},
    +					`
    +					try {
    +						require('${name}');
    +						module.exports = 'ESCAPED';
    +					} catch (e) { module.exports = 'BLOCKED'; }
    +				`
    +				);
    +			});
    +
    +			it(`blocked under explicit ['${name}']`, () => {
    +				expectBuiltinBlocked(
    +					`${name}-explicit`,
    +					{builtin: [name]},
    +					`
    +					try {
    +						require('${name}');
    +						module.exports = 'ESCAPED';
    +					} catch (e) { module.exports = 'BLOCKED'; }
    +				`
    +				);
    +			});
    +
    +			it('blocked under node: prefix', () => {
    +				expectBuiltinBlocked(
    +					`${name}-node-prefix`,
    +					{builtin: ['*']},
    +					`
    +					try {
    +						require('node:${name}');
    +						module.exports = 'ESCAPED';
    +					} catch (e) { module.exports = 'BLOCKED'; }
    +				`
    +				);
    +			});
    +
    +			it('blocked when builtin is an object map', () => {
    +				const opts = {};
    +				opts[name] = true;
    +				expectBuiltinBlocked(
    +					`${name}-object-map`,
    +					{builtin: opts},
    +					`
    +					try {
    +						require('${name}');
    +						module.exports = 'ESCAPED';
    +					} catch (e) { module.exports = 'BLOCKED'; }
    +				`
    +				);
    +			});
    +		});
    +	}
    +
    +	describe('confirmed exploitation paths from the advisory PoC', () => {
    +		// SECURITY: these tests assert the *primitive* the advisory PoC depends
    +		// on is unreachable. We don't actually wire up an HTTP server inside the
    +		// test -- if `require('diagnostics_channel')` itself fails, the entire
    +		// `channel(...).subscribe(cb)` chain is unreachable too.
    +		it('diagnostics_channel.channel().subscribe is unreachable', () => {
    +			expectBuiltinBlocked(
    +				'diagnostics_channel-subscribe',
    +				{builtin: ['*']},
    +				`
    +				try {
    +					const dc = require('diagnostics_channel');
    +					const ch = dc.channel('http.server.request.start');
    +					if (typeof ch.subscribe === 'function') module.exports = 'ESCAPED';
    +					else module.exports = 'BLOCKED';
    +				} catch (e) { module.exports = 'BLOCKED'; }
    +			`
    +			);
    +		});
    +
    +		it('async_hooks.executionAsyncResource is unreachable', () => {
    +			expectBuiltinBlocked(
    +				'async_hooks-executionAsyncResource',
    +				{builtin: ['*']},
    +				`
    +				try {
    +					const ah = require('async_hooks');
    +					if (typeof ah.executionAsyncResource === 'function') module.exports = 'ESCAPED';
    +					else module.exports = 'BLOCKED';
    +				} catch (e) { module.exports = 'BLOCKED'; }
    +			`
    +			);
    +		});
    +
    +		it('perf_hooks.performance.getEntriesByType is unreachable', () => {
    +			expectBuiltinBlocked(
    +				'perf_hooks-getEntriesByType',
    +				{builtin: ['*']},
    +				`
    +				try {
    +					const ph = require('perf_hooks');
    +					if (ph.performance && typeof ph.performance.getEntriesByType === 'function') module.exports = 'ESCAPED';
    +					else module.exports = 'BLOCKED';
    +				} catch (e) { module.exports = 'BLOCKED'; }
    +			`
    +			);
    +		});
    +
    +		it('v8.getHeapSnapshot / writeHeapSnapshot are unreachable', () => {
    +			expectBuiltinBlocked(
    +				'v8-heap-snapshot',
    +				{builtin: ['*']},
    +				`
    +				try {
    +					const v8 = require('v8');
    +					if (typeof v8.getHeapSnapshot === 'function' || typeof v8.writeHeapSnapshot === 'function') module.exports = 'ESCAPED';
    +					else module.exports = 'BLOCKED';
    +				} catch (e) { module.exports = 'BLOCKED'; }
    +			`
    +			);
    +		});
    +	});
    +
    +	describe('low-level makeBuiltins API', () => {
    +		// SECURITY: covers `makeBuiltins([...])` consumers that build their own
    +		// resolver. The `addDefaultBuiltin` denial closes this path too.
    +		it('makeBuiltins([...observability]) registers none of them', () => {
    +			const map = makeBuiltins(OBSERVABILITY_BUILTINS, require);
    +			for (const name of OBSERVABILITY_BUILTINS) {
    +				assert.strictEqual(map.has(name), false, `${name} must be absent from the builtins map`);
    +			}
    +		});
    +	});
    +
    +	describe('non-observability builtins still load', () => {
    +		// Regression guard: the denylist must not over-fire onto unrelated names
    +		// that happen to share a prefix or namespace.
    +		it('fs is reachable', () => {
    +			const vm = new NodeVM({require: {builtin: ['fs'], external: false}});
    +			assert.strictEqual(vm.run("module.exports = typeof require('fs').readFileSync"), 'function');
    +		});
    +
    +		it("path is reachable under ['*']", () => {
    +			const vm = new NodeVM({require: {builtin: ['*'], external: false}});
    +			assert.strictEqual(vm.run("module.exports = typeof require('path').join"), 'function');
    +		});
    +	});
    +
    +	describe('mocks/overrides escape hatch is preserved', () => {
    +		// SECURITY: embedders who genuinely need a sandbox-local replacement
    +		// (e.g. a controlled perf_hooks stub that only exposes sandbox-internal
    +		// timing) can still register one. The denylist rejects the *default
    +		// host-passthrough loader*, not user-supplied wrappers.
    +		it('mock diagnostics_channel is honored', () => {
    +			const vm = new NodeVM({
    +				require: {
    +					builtin: ['*'],
    +					external: false,
    +					mock: {diagnostics_channel: {safe: 42}}
    +				}
    +			});
    +			assert.strictEqual(vm.run("module.exports = require('diagnostics_channel').safe"), 42);
    +		});
    +
    +		it('mock v8 is honored', () => {
    +			const vm = new NodeVM({
    +				require: {
    +					builtin: ['*'],
    +					external: false,
    +					mock: {v8: {tag: 'sandbox-safe'}}
    +				}
    +			});
    +			assert.strictEqual(vm.run("module.exports = require('v8').tag"), 'sandbox-safe');
    +		});
    +	});
    +});
    

Vulnerability mechanics

Root cause

"The `DANGEROUS_BUILTINS` denylist in `lib/builtin.js` did not include process-wide observability modules (`diagnostics_channel`, `async_hooks`, `perf_hooks`, `v8`), allowing sandboxed code to read host-process state through readonly proxies that cannot contain inherently host-scoped data."

Attack vector

An attacker who can run untrusted JavaScript inside a `NodeVM` instance where `require.builtin` includes `'*'` (wildcard) or explicitly names `diagnostics_channel`, `async_hooks`, or `perf_hooks` can read host-process data. The sandbox calls `require('diagnostics_channel').channel('http.server.request.start').subscribe(cb)` to receive raw host `IncomingMessage` objects with `Authorization` and session-token headers, `require('async_hooks').executionAsyncResource()` to read the host's current `AsyncResource` (which often carries per-request user/auth state in `AsyncLocalStorage` patterns), or `require('perf_hooks').performance.getEntriesByType('mark')` to enumerate host performance marks that may contain request IDs or user identifiers [ref_id=1]. No code execution is required — reaching the host module is the escape [CWE-668].

Affected code

The vulnerability resides in `lib/builtin.js` where the `DANGEROUS_BUILTINS` set did not include `diagnostics_channel`, `async_hooks`, `perf_hooks`, or `v8`. The `builtins.set(key, special ? special : vm => vm.readonly(hostRequire(key)))` mechanism in `lib/builtin.js` exposed these host modules to the sandbox as readonly proxies, but the data they surface is inherently host-process state that cannot be contained by the proxy layer [patch_id=3104920].

What the fix does

The patch extends the `DANGEROUS_BUILTINS` set in `lib/builtin.js` with `diagnostics_channel`, `async_hooks`, `perf_hooks`, and `v8` [patch_id=3104920]. These names are filtered out of `BUILTIN_MODULES` (closing the `'*'` wildcard path) and rejected inside `addDefaultBuiltin` via `isDangerousBuiltin(key)` (closing explicit allowlists, object-map forms, and the low-level `makeBuiltins([...])` API). The `node:` prefix is normalized before lookup, and family-prefix subpath matching is applied. The `mocks`/`overrides` escape hatch is preserved so embedders can register sandbox-local wrappers under the same names if needed [ref_id=1].

Preconditions

  • configThe NodeVM instance must have `require.builtin` configured with `'*'` (wildcard) or explicitly include `diagnostics_channel`, `async_hooks`, or `perf_hooks`.
  • inputThe attacker must be able to supply and execute arbitrary JavaScript code inside the NodeVM sandbox.
  • configThe host application must use the exposed APIs (HTTP server, AsyncLocalStorage, performance marks) in the same process for meaningful data to be observable.

Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.