NodeVM network builtin exclusions bypass via internal _http_client and _http_server
Description
Summary
NodeVM supports excluding public network builtins from the wildcard builtin option. With this configuration direct access to http, https, http2, net, dgram, tls, dns, and dns/promises is blocked.
However, Node.js also exposes underscored internal HTTP builtins such as _http_client and _http_server. These are not blocked when the public modules are excluded.
Sandboxed code can use these internal builtins to make outbound HTTP requests and open listening HTTP sockets even though the public network modules are denied.
Note: This is not host RCE. It is a network capability bypass that can lead to SSRF-style access to internal services.
Details
The wildcard builtin expansion is based on Node.js builtin module names:
const BUILTIN_MODULES = (nmod.builtinModules || Object.getOwnPropertyNames(process.binding('natives')))
.filter(s=>!s.startsWith('internal/') && !DANGEROUS_BUILTINS.has(s));
Public modules can be excluded with -name:
if (builtins.indexOf(`-${name}`) === -1) {
addDefaultBuiltin(res, name, hostRequire);
}
But excluding http and net does not exclude internal siblings such as:
_http_client
_http_server
_tls_wrap
These internal modules expose network primitives.
Confirmed examples:
require('_http_client').ClientRequest(...)performs an outbound HTTP request to a host-local service whilehttpandnetare blocked.require('_http_server').Server(...).listen(...)opens a listening HTTP socket whilehttpandnetare blocked.
PoC
Tested on:
vm2: 3.11.2
Node.js: v25.9.0
Run from the vm2 repository root:
node poc/internal-http-builtin-network-bypass.js
internal-http-builtin-network-bypass.js
The PoC first confirms the intended restrictions work then bypasses them:
require("_http_client").ClientRequest(...)
This performs an HTTP request to a host-local service and reads the response.
It also confirms:
require("_http_server").Server(...).listen(0)
This opens a listening HTTP socket from inside the sandbox.
Impact
An attacker who can run untrusted JavaScript inside NodeVM with this affected builtin configuration can regain network access even when the application attempted to block network modules.
This can allow SSRF-style access to localhost services, metadata endpoints, internal admin panels, or other network resources reachable from the host process.
Suggested fix
Treat underscored internal network modules as dangerous or link their availability to the public module they wrap.
At minimum, exclude related internal modules such as:
_http_agent
_http_client
_http_common
_http_incoming
_http_outgoing
_http_server
_tls_common
_tls_wrap
Alternatively, deny underscored Node.js internals from wildcard builtin expansion by default.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
NodeVM's wildcard builtin exclusion fails to block internal underscore-prefixed network modules, enabling SSRF-style bypass.
Vulnerability
The NodeVM sandbox supports a wildcard builtin option ('*') that can be narrowed by excluding public network modules like http, https, net, etc. However, the wildcard expansion includes Node.js's underscored internal builtins such as _http_client and _http_server (and others like _tls_wrap), which are not filtered out when the public modules are denied. This allows sandboxed code to bypass network restrictions. The issue affects vm2 versions before 3.11.4 [1][2][3][4].
Exploitation
An attacker who can execute untrusted JavaScript inside a NodeVM instance configured with the pattern ['*', '-http', '-https', '-net', ...] can simply call require('_http_client').ClientRequest(...) to make outbound HTTP requests, or require('_http_server').Server(...).listen(...) to open a listening HTTP socket. No additional privileges are needed beyond sandbox code execution [3][4].
Impact
Successful exploitation allows the attacker to perform network operations that were explicitly blocked, enabling SSRF-style access to internal services, data exfiltration, or lateral movement. This is a network capability bypass; it does not directly lead to host RCE [3][4].
Mitigation
Upgrade to vm2 version 3.11.4 or later, which was released on 2026-05-29 and includes a fix that removes underscored builtins from the wildcard expansion [1][2]. There are no known workarounds other than avoiding the wildcard builtin option or explicitly listing all allowed modules [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- Range: <= 3.11.3
- Range: =3.11.2
Patches
1436053e30eecfix(GHSA-r9pm-gxmw-wv6p): exclude underscored builtins from NodeVM '*' wildcard expansion
4 files changed · +316 −2
CHANGELOG.md+2 −1 modified@@ -2,7 +2,7 @@ ## [3.11.4] -Eight advisories closed. Patch release — no API changes for valid configurations. +Nine advisories closed. Patch release — no API changes for valid configurations. ### Security fixes @@ -14,6 +14,7 @@ Eight advisories closed. Patch release — no API changes for valid configuratio - **GHSA-m4wx-m65x-ghrr** — patch bypass of GHSA-8hg8-63c5-gwmx. The original check `options.require === false` only rejected the literal `require: false` shape; omitting `require` entirely left `options.require === undefined`, the check fell through, and the destructuring default `require: requireOpts = false` a few lines down produced the same `requireOpts = false` the original patch existed to block — inner NodeVM construction with attacker-chosen `require` config → `child_process` RCE. Structural fix in `lib/nodevm.js`: destructure first, then check `nesting === true && !requireOpts`, so every falsy/omitted shape collapses to the same construction-time `VMError`. The explicit-`require`-config escape hatch is preserved. 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/`. ### Upgrade notes
docs/ATTACKS.md+99 −0 modified@@ -2793,6 +2793,104 @@ JSPI is the first known instance of this third class; future spec extensions tha --- +## Attack Category 34: NodeVM Wildcard Exposes Undocumented Underscored Builtins — Network Capability Bypass + +### Description + +NodeVM's `'*'` wildcard expansion (in `lib/builtin.js`) sources the list of allowed builtins from `require('module').builtinModules` filtered by `s => !s.startsWith('internal/') && !DANGEROUS_BUILTINS.has(s)`. The filter removes Node's `internal/*` modules and the host-passthrough denylist from Category 21, but it does **not** remove the parallel family of underscored builtins: + +``` +_http_agent _http_common _http_outgoing _tls_common _stream_readable +_http_client _http_incoming _http_server _tls_wrap _stream_writable + _stream_duplex + _stream_transform + _stream_wrap + _stream_passthrough +``` + +These are Node's private implementation modules backing `http`, `https`, `tls`, and the streams subsystem. They are listed in `builtinModules` (so the wildcard expands to them) but they are not documented public API and they expose the network primitives directly: + +- `require('_http_client').ClientRequest(opts)` — outbound HTTP request, **bypasses `http`/`https` blocking**. +- `require('_http_server').Server(handler).listen(0)` — listening HTTP socket, **bypasses `net` blocking**. +- `require('_tls_wrap').TLSSocket` / `_tls_common` — TLS primitives, **bypass `tls` blocking**. + +### Attack Flow + +1. Embedder writes the documented "allow everything except network" pattern: + ```javascript + new NodeVM({require: {builtin: ['*', '-http', '-https', '-net', '-dgram', '-tls', '-dns', '-dns/promises', '-http2']}}) + ``` +2. The `'*'` wildcard expands to `BUILTIN_MODULES`, which (pre-fix) includes every `_http_*` and `_tls_*` sibling because none of them match `internal/` or `DANGEROUS_BUILTINS`. +3. Sandbox code calls `require('_http_client')`. The allowlist contains it, `addDefaultBuiltin` wraps the host module in `vm.readonly()`, and the proxy is handed to the sandbox. +4. Sandbox calls `new (require('_http_client').ClientRequest)({host: '127.0.0.1', port: 80, ...})` — outbound HTTP request from the host. Equivalent attack via `_http_server` opens a listening socket. + +### Canonical Example + +```javascript +// (advisory GHSA-r9pm-gxmw-wv6p) +const vm = new NodeVM({ + require: { + builtin: ['*', '-http', '-https', '-net', '-dgram', '-tls', '-dns', '-dns/promises', '-http2'], + external: false + } +}); +vm.run(` + const {ClientRequest} = require('_http_client'); + const req = new ClientRequest({host: '169.254.169.254', port: 80, path: '/latest/meta-data/'}); + req.on('response', r => r.on('data', d => module.exports = d.toString())); + req.end(); +`, 'poc.js'); +``` + +The user's mental model — "I excluded `http` and `net`, the sandbox cannot make HTTP requests" — is silently violated. + +### Why It Works + +`require('module').builtinModules` is Node's flat list of every builtin name, including private implementation siblings. The `'-name'` exclusion mechanism in vm2 is purely string-equality based — `'-http'` does not cascade to `_http_client`, `_http_server`, etc. The mismatch between the wildcard source (full builtin list) and the embedder's mental model (documented public modules) is the bug. An exclusion-based config can never name all the siblings because Node may introduce new underscored builtins between releases. + +This is **not** RCE — the underscored siblings load like any other vetted builtin and the bridge proxy applies normally. The impact is capability bypass: the sandbox regains the very capability the embedder explicitly attempted to remove. CVSS 8.6 reflects the SSRF-class blast radius (cloud metadata endpoints, internal admin panels, localhost-only services). + +### Mitigation + +Filter modules whose name starts with `_` from the `BUILTIN_MODULES` source in `lib/builtin.js`: + +```javascript +const BUILTIN_MODULES = (nmod.builtinModules || Object.getOwnPropertyNames(process.binding('natives'))) + .filter(s => !s.startsWith('internal/') && !s.startsWith('_') && !isDangerousBuiltin(s)); +``` + +After the fix, the `'*'` wildcard expands only to documented public Node builtins. The `'-name'` exclusion mechanism is again coherent — excluding `http`/`net`/`tls` removes every reachable network builtin under the wildcard. Both bare-name (`require('_http_client')`) and `node:`-prefixed (`require('node:_http_client')`) forms are blocked because the builtins map is the single source of truth (`loadBuiltinModule` returns `undefined` for absent keys, so the sandbox-side `requireImpl` throws `ENOTFOUND`). + +**Escape hatches preserved.** The fix is intentionally narrow: + +- **Explicit opt-in** still works. A power user who genuinely needs `_http_client` can list it directly (`builtin: ['_http_client']` or `makeBuiltins(['_http_client'])`) — `addDefaultBuiltin` does not consult the `s.startsWith('_')` filter. +- **`mock` / `override`** registrations under underscored names continue to function — they bypass `addDefaultBuiltin` entirely. + +### Defense Invariant Enforced + +> **The `'*'` wildcard expands only to documented public Node builtins. Undocumented underscored siblings of network and stream modules MUST NOT be reachable from sandbox code under the wildcard expansion. Explicit opt-in remains the user's choice.** + +This complements [Category 21](#attack-category-21-nodevm-builtin-allowlist-bypass-via-host-passthrough-builtins)'s `DANGEROUS_BUILTINS` invariant (and the [Defense Invariant #13](#defense-invariants) it restores). Category 21 is "host-passthrough primitives are unreachable under any config"; Category 34 is "wildcard expansion follows the user's mental model of public APIs only". + +### Detection Rules + +- **`require('_http_client')` / `require('_http_server')` / `require('_tls_wrap')`** from sandbox code — canonical bypass primitives. +- **`require('node:_http_client')`** (etc.) — `node:` prefix path, equivalent reachability. +- **Embedder config `builtin: ['*', '-http', ...]`** — historically left every `_http_*`/`_tls_*` sibling reachable; now safe. + +### Considered Attack Surfaces + +- **`require('module').builtinModules` published as `Module.builtinModules` inside the sandbox** (`lib/setup-node-sandbox.js:140`) — this is a static metadata list, not a loader. Sandbox code seeing `_http_client` in the list does not gain the ability to load it; the resolver gates by `this.builtins.has(x)`. +- **Custom resolvers building their own builtins map via `makeBuiltinsFromLegacyOptions`** — same source list (`BUILTIN_MODULES`), same filter, same protection. +- **`hostRequire` registered by `mock` / `override`** — out of scope. The user is explicitly handing the sandbox a module; trust is the user's responsibility. +- **Underscored siblings introduced by future Node versions** — the `s.startsWith('_')` filter is name-based and forward-compatible. Any new `_foo_bar` builtin Node adds is automatically excluded from the wildcard without requiring a vm2 release. + +### Supersedes + +None. This fix complements [Category 21](#attack-category-21-nodevm-builtin-allowlist-bypass-via-host-passthrough-builtins) (`DANGEROUS_BUILTINS`) — together they enforce: "no host-passthrough primitive AND no undocumented underscored sibling is reachable under `builtin: ['*']`." + +--- + ## 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. @@ -2922,6 +3020,7 @@ The most dangerous attacks combine multiple categories. Each pattern references | Host prototype mutation via apply trap (GHSA-v6mx-mf47-r5wg) | Apply trap caches the host prototype-mutating intrinsics (`Object.prototype.__proto__` setter, `Object.setPrototypeOf`, `Reflect.setPrototypeOf`, `Object.{defineProperty,defineProperties}`, `Reflect.defineProperty`, `__defineSetter__`, `__defineGetter__`) in `dangerousHostProtoMutators` and refuses any invocation reaching them — direct or via one-layer indirection through `Function.prototype.{call,apply,bind}` / `Reflect.{apply,construct}`. Read-side defense-in-depth in `thisEnsureThis` cache-checks `mappingOtherToThis` before the proto-walk so any previously-bridged host value returns the existing proxy even when its prototype chain has been tampered with by some other route. | | 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`. | ### Key Security Invariant: Promise Species Resolution Timing
lib/builtin.js+13 −1 modified@@ -115,8 +115,20 @@ function isDangerousBuiltin(key) { return false; } +// SECURITY (GHSA-r9pm-gxmw-wv6p): Underscored builtins (_http_client, +// _http_server, _http_agent, _http_common, _http_incoming, _http_outgoing, +// _tls_common, _tls_wrap, _stream_*) are Node's private implementation +// modules backing http/https/tls/streams. They are listed by +// `require('module').builtinModules` but are not documented public API and +// expose network primitives directly (`_http_client.ClientRequest`, +// `_http_server.Server`). Filtering them at the `BUILTIN_MODULES` source +// removes them from `'*'` wildcard expansion, so the documented +// `builtin: ['*', '-http', '-https', '-net', '-tls', ...]` pattern is +// once again coherent. Explicit opt-in (`builtin: ['_http_client']`) and +// `mock`/`override` registrations remain functional via `addDefaultBuiltin` +// -- power users who genuinely need an internal sibling can still name it. const BUILTIN_MODULES = (nmod.builtinModules || Object.getOwnPropertyNames(process.binding('natives'))) - .filter(s=>!s.startsWith('internal/') && !isDangerousBuiltin(s)); + .filter(s=>!s.startsWith('internal/') && !s.startsWith('_') && !isDangerousBuiltin(s)); let EventEmitterReferencingAsyncResourceClass = null; if (EventEmitter.EventEmitterAsyncResource) {
test/ghsa/GHSA-r9pm-gxmw-wv6p/repro.js+202 −0 added@@ -0,0 +1,202 @@ +/** + * GHSA-r9pm-gxmw-wv6p — NodeVM network builtin exclusions bypass via internal _http_client / _http_server + * + * + * ## Vulnerability + * `BUILTIN_MODULES` in `lib/builtin.js` is sourced from + * `require('module').builtinModules` filtered by `s => !s.startsWith('internal/') && + * !DANGEROUS_BUILTINS.has(s)`. This source feeds the `'*'` wildcard expansion in + * `makeBuiltinsFromLegacyOptions` — so every Node builtin that survives the filter + * is implicitly handed to the sandbox. + * + * Node exposes a parallel family of underscored builtins (`_http_client`, + * `_http_server`, `_http_agent`, `_http_common`, `_http_incoming`, + * `_http_outgoing`, `_tls_common`, `_tls_wrap`, `_stream_*`). These are the + * private implementation modules that back `http`, `https`, and `tls`. They are + * not documented public API but they ARE in `require('module').builtinModules` + * and they DO expose the network primitives directly: + * + * require('_http_client').ClientRequest(opts) -> outbound HTTP request + * require('_http_server').Server(...).listen() -> listening HTTP socket + * + * Pre-fix, `builtin: ['*', '-http', '-https', '-net', '-dgram', '-tls', + * '-dns', '-dns/promises', '-http2']` — the documented "allow everything + * except network" pattern — silently allowed every `_http_*` and `_tls_*` + * sibling, fully bypassing the embedder's network restriction. SSRF-class + * impact (CVSS 8.6: localhost services, cloud metadata endpoints, internal + * admin panels). + * + * ## Fix + * Filter out modules whose name starts with `_` from `BUILTIN_MODULES`. The + * `'*'` wildcard no longer expands to any underscored sibling, so excluding + * `http` / `net` / `tls` via `-name` is once again coherent. Explicit + * opt-in (`builtin: ['_http_client']`) and `mock`/`override` registrations + * remain functional — power users who genuinely need an internal sibling + * can still name it. The `node:_http_client` form also stops working under + * the wildcard because the builtins map is the single source of truth and + * `loadBuiltinModule` returns undefined for any key not in the map. + * + * Invariant enforced: "The `'*'` wildcard expands only to documented public + * Node builtins. Undocumented underscored siblings of network modules MUST + * NOT be reachable from sandbox code under the wildcard expansion." + */ + +'use strict'; + +const assert = require('assert'); +const {NodeVM} = require('../../../lib/main.js'); +const {makeBuiltins} = require('../../../lib/builtin.js'); + +// Underscored builtins that wrap public network modules. These must never be +// reachable from the sandbox under the `'*'` wildcard expansion. +const UNDERSCORED_NETWORK = [ + '_http_agent', + '_http_client', + '_http_common', + '_http_incoming', + '_http_outgoing', + '_http_server', + '_tls_common', + '_tls_wrap' +]; + +function runRequire(vm, name) { + return vm.run(` + try { + const m = require(${JSON.stringify(name)}); + module.exports = {ok: true, hasClientRequest: typeof (m && m.ClientRequest) === 'function', hasServer: typeof (m && m.Server) === 'function'}; + } catch (e) { + module.exports = {ok: false, code: e && e.code, message: e && e.message}; + } + `); +} + +describe('GHSA-r9pm-gxmw-wv6p -- underscored network builtins bypass via wildcard', () => { + + describe('underscored network builtins are blocked under wildcard', () => { + + it("`builtin: ['*']` does NOT expose _http_client", () => { + const vm = new NodeVM({require: {builtin: ['*'], external: false}}); + const r = runRequire(vm, '_http_client'); + assert.strictEqual(r.ok, false, `_http_client must NOT load, got ${JSON.stringify(r)}`); + }); + + it("`builtin: ['*']` does NOT expose _http_server", () => { + const vm = new NodeVM({require: {builtin: ['*'], external: false}}); + const r = runRequire(vm, '_http_server'); + assert.strictEqual(r.ok, false, `_http_server must NOT load, got ${JSON.stringify(r)}`); + }); + + it("`builtin: ['*', '-http', '-https', '-net', '-tls']` does NOT expose any _http_*/_tls_* sibling (canonical PoC scenario)", () => { + const vm = new NodeVM({ + require: { + builtin: ['*', '-http', '-https', '-net', '-dgram', '-tls', '-dns', '-dns/promises', '-http2'], + external: false + } + }); + for (const name of UNDERSCORED_NETWORK) { + const r = runRequire(vm, name); + assert.strictEqual(r.ok, false, `${name} must NOT load under network-excluded wildcard, got ${JSON.stringify(r)}`); + } + }); + + it("`builtin: ['*']` does NOT expose underscored network builtins via the `node:` prefix", () => { + const vm = new NodeVM({require: {builtin: ['*'], external: false}}); + for (const name of UNDERSCORED_NETWORK) { + const r = runRequire(vm, `node:${name}`); + assert.strictEqual(r.ok, false, `node:${name} must NOT load, got ${JSON.stringify(r)}`); + } + }); + + it('underscored stream internals are also excluded from the wildcard', () => { + const vm = new NodeVM({require: {builtin: ['*'], external: false}}); + for (const name of ['_stream_readable', '_stream_writable', '_stream_duplex', '_stream_transform', '_stream_wrap']) { + const r = runRequire(vm, name); + assert.strictEqual(r.ok, false, `${name} must NOT load under wildcard, got ${JSON.stringify(r)}`); + } + }); + + }); + + describe('canonical PoC: ClientRequest / Server primitives are unreachable', () => { + + // Direct reproduction of the canonical PoC: load _http_client and + // verify the `ClientRequest` constructor — the actual escape primitive + // — is not handed to the sandbox. + it('_http_client.ClientRequest is not callable from the sandbox', () => { + const vm = new NodeVM({ + require: { + builtin: ['*', '-http', '-https', '-net', '-dgram', '-tls', '-dns', '-dns/promises', '-http2'], + external: false + } + }); + const r = runRequire(vm, '_http_client'); + assert.strictEqual(r.ok, false); + }); + + it('_http_server.Server is not callable from the sandbox', () => { + const vm = new NodeVM({ + require: { + builtin: ['*', '-http', '-https', '-net', '-dgram', '-tls', '-dns', '-dns/promises', '-http2'], + external: false + } + }); + const r = runRequire(vm, '_http_server'); + assert.strictEqual(r.ok, false); + }); + + }); + + describe('low-level makeBuiltins API', () => { + + // Wildcard expansion happens in makeBuiltinsFromLegacyOptions. The + // invariant is enforced at the source list, so a wildcard-equivalent + // caller building from `BUILTIN_MODULES` should never receive an + // underscored entry. We test the observable surface: `makeBuiltins` + // with the underscored names still produces an entry (explicit opt-in + // is preserved), but the wildcard-style `builtin: ['*']` path does not. + it('explicit opt-in via makeBuiltins still works (power-user escape hatch)', () => { + // SECURITY: do not over-block. A user who explicitly types + // `builtin: ['_http_client']` is opting in knowingly. Only the + // wildcard expansion is tightened. + const map = makeBuiltins(['_http_client'], require); + assert.strictEqual(map.has('_http_client'), true); + }); + + }); + + describe('non-underscored builtins still load under wildcard', () => { + + it("fs loads under ['*']", () => { + const vm = new NodeVM({require: {builtin: ['*'], external: false}}); + assert.strictEqual(vm.run("module.exports = typeof require('fs').readFileSync"), 'function'); + }); + + it("path loads under ['*']", () => { + const vm = new NodeVM({require: {builtin: ['*'], external: false}}); + assert.strictEqual(vm.run("module.exports = typeof require('path').join"), 'function'); + }); + + it("events loads under ['*']", () => { + const vm = new NodeVM({require: {builtin: ['*'], external: false}}); + assert.strictEqual(vm.run("module.exports = typeof require('events').EventEmitter"), 'function'); + }); + + }); + + describe('mocks/overrides escape hatch is preserved for underscored names', () => { + + it('mock _http_client is honored', () => { + const vm = new NodeVM({ + require: { + builtin: ['*'], + external: false, + mock: {_http_client: {safe: 7}} + } + }); + assert.strictEqual(vm.run("module.exports = require('_http_client').safe"), 7); + }); + + }); + +});
Vulnerability mechanics
Root cause
"The `BUILTIN_MODULES` filter in `lib/builtin.js` does not exclude Node's undocumented underscored builtins (`_http_client`, `_http_server`, `_tls_wrap`, etc.), so the `'*'` wildcard expansion silently admits network primitives that bypass the documented `-http`/`-net`/`-tls` exclusions."
Attack vector
An attacker who can execute untrusted JavaScript inside a `NodeVM` instance configured with the documented "allow everything except network" pattern — `builtin: ['*', '-http', '-https', '-net', '-dgram', '-tls', '-dns', '-dns/promises', '-http2']` — can bypass the network exclusions by requiring Node's undocumented underscored builtins [ref_id=1][ref_id=2]. For example, `require('_http_client').ClientRequest({host, port, path})` performs an outbound HTTP request from the host process, and `require('_http_server').Server(handler).listen(0)` opens a listening HTTP socket [ref_id=1][ref_id=3]. This is an SSRF-class capability bypass (CVSS 8.6) that can reach localhost services, cloud metadata endpoints, and internal admin panels [ref_id=1][ref_id=2].
Affected code
The vulnerability resides in `lib/builtin.js` where `BUILTIN_MODULES` is sourced from `require('module').builtinModules` and filtered only against `internal/*` and `DANGEROUS_BUILTINS` — it does **not** filter underscored builtins such as `_http_client`, `_http_server`, `_tls_wrap`, and the `_stream_*` family [ref_id=1]. The `'*'` wildcard expansion in `makeBuiltinsFromLegacyOptions` therefore silently admits these private implementation modules, and the `'-name'` exclusion mechanism (e.g. `-http`, `-net`) is purely string-equality based and does not cascade to their underscored siblings [ref_id=1][ref_id=2].
What the fix does
The patch adds `!s.startsWith('_')` to the `BUILTIN_MODULES` filter in `lib/builtin.js`, so the `'*'` wildcard now expands only to documented public Node builtins [patch_id=3104223]. Underscored siblings like `_http_client`, `_http_server`, `_tls_wrap`, and the `_stream_*` family are excluded from the wildcard expansion, making the `'-name'` exclusion mechanism coherent again [patch_id=3104223]. Explicit opt-in (`builtin: ['_http_client']`) and `mock`/`override` registrations remain functional for power users who genuinely need an internal sibling [ref_id=1].
Preconditions
- configThe NodeVM instance must be configured with the wildcard builtin option `builtin: ['*']` (or a pattern that uses `'*'` with exclusions such as `-http`, `-net`).
- inputThe attacker must be able to execute arbitrary JavaScript code inside the NodeVM sandbox.
- networkThe host process must have network connectivity to the target services (localhost, cloud metadata endpoints, internal networks).
Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.