You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(GHSA-947f-4v7f-x2v8): deny host-passthrough builtins from NodeVM allowlist
Six Node builtins expose primitives whose primary capability is
"reach host code regardless of the sandbox boundary":
- module: Module._load loads any builtin/external ignoring allowlist
- worker_threads: new Worker(src, {eval:true}) runs unsandboxed JS
- cluster: cluster.fork() spawns host child process
- vm: vm.runInThisContext evaluates in host realm
- repl: repl.start() = interactive host evaluator
- inspector: inspector.open() attaches debugger to host process
Users wrote `builtin: ['*', '-child_process']` expecting "everything
except child_process", but `*` expanded to include `module`, and
ReadOnlyHandler's apply trap forwarded Module._load straight to the
host — bypassing the exclusion.
Two-layer denylist in lib/builtin.js:
- DANGEROUS_BUILTINS Set excluded from BUILTIN_MODULES (closes the `*`
wildcard path).
- addDefaultBuiltin rejects DANGEROUS_BUILTINS even for explicit
`builtin: ['module']` configs and the lower-level makeBuiltins API
used by custom resolvers.
SPECIAL_MODULES, mocks, and overrides escape hatches are preserved.
Non-dangerous builtins (fs, events, path, buffer, util, etc.) continue
to load normally.
NodeVM's `require.builtin` allowlist defends sandbox code from reaching dangerous Node modules (`child_process`, `fs`, etc.). The allowlist is enforced by `lib/builtin.js` — when sandbox code calls `require(name)`, the resolver consults the allowlist and only loads modules the user opted in to. **However**, several Node builtins themselves expose primitives whose primary capability is "reach host code regardless of the sandbox boundary". When such a builtin is on the allowlist (or, more commonly, included by the `'*'` wildcard), it becomes a single-line allowlist bypass:
1466
+
1467
+
- `module` exposes `Module._load(name)`, `Module._resolveFilename`, `Module._cache`, `createRequire` — all of which load any host builtin or external module ignoring vm2's allowlist.
1468
+
- `worker_threads` exposes `newWorker(src, {eval:true})` — runs arbitrary JS in a fresh thread that has no vm2 sandbox at all.
1469
+
- `cluster` exposes `cluster.fork()` — spawns a host child process running attacker-controlled code.
1470
+
- `vm` exposes `vm.runInThisContext` — evaluates code directly in the host realm, bypassing every bridge proxy.
1471
+
- `repl` exposes `repl.start({eval, input, output})` — constructs an interactive evaluator attached to host streams.
1472
+
- `inspector` exposes the inspector protocol — attaches a debugger to the host process.
1473
+
1474
+
### Attack Flow
1475
+
1476
+
1. **Allowlist includes a host-passthrough builtin** (most commonly because the user wrote `builtin: ['*', '-child_process']` and `'*'` expanded to include `'module'`).
1477
+
2. **Sandbox calls `require('module')`**. NodeVM's resolver finds `'module'` in `BUILTIN_MODULES`, calls `addDefaultBuiltin` which loads it via `vm.readonly(hostRequire('module'))`. The `ReadOnlyHandler` proxy blocks mutation traps but *not* `apply`/`get` — calling methods on the proxy still forwards them to the host realm.
1478
+
3. **Sandbox calls `Module._load('child_process')`**. The bridge `apply` trap forwards to host `Module._load`, which loads `child_process` natively in the host with no vm2 check.
The user's mental model of `['*', '-child_process']` is "every builtin except `child_process`". That model assumes every builtin is either fully sandboxed or fully blocked — but `module` (and its peers above) are neither. They're *meta-builtins* that load other builtins by name. The generic `vm.readonly()` wrapper cannot make them safe because the sandbox-bypass primitive is the very thing the user is calling.
1498
+
1499
+
### Mitigation
1500
+
1501
+
Two-layer denylist enforcement in `lib/builtin.js`:
2. **Filter from `BUILTIN_MODULES`** — closes the `'*'` wildcard expansion path. `'*'` will never auto-allow these names regardless of the user's exclusion list.
1505
+
3. **Reject in `addDefaultBuiltin`** — closes the explicit-allowlist path (`builtin: ['module']`) and the lower-level `makeBuiltins(['module'])` 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.
1506
+
1507
+
The fix does not affect the `mocks` / `overrides` escape hatches — users who genuinely need a stub for one of these names can register a sandbox-safe replacement.
1508
+
1509
+
### Detection Rules
1510
+
1511
+
- **`builtin: ['*']` or `['*', '-X']`** in NodeVM config — historically allowed `module`/`worker_threads`/`cluster`/`vm`/`repl`/`inspector`, now safely filtered.
1512
+
- **`require('module')._load(...)`** — the canonical bypass primitive.
- **`inspector.open()`** — debugger attachment to host process.
1518
+
1519
+
### Considered Attack Surfaces
1520
+
1521
+
- **`async_hooks`** exposes context tracing but not host-code-loading primitives. Allowed under `'*'`.
1522
+
- **`child_process`** is the canonical "user knows to exclude this" builtin — not on the auto-denylist because users may legitimately want it for trusted scripts. The `'*'` wildcard with no exclusion still allows it.
1523
+
- **`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.
1524
+
1525
+
---
1526
+
1461
1527
## Considered Attack Surfaces
1462
1528
1463
1529
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.
0 commit comments