Details
## Summary
NodeVM normalizes `node:`-prefixed builtin specifiers during `require()` resolution, but it does not normalize user-provided negative builtin entries in wildcard policy.
As a result, this configuration:
```js
new NodeVM({
require: {
builtin: ['*', '-node:child_process']
}
});
```
does not deny the canonical `child_process` builtin. Sandboxed code can require both `child_process` and `node:child_process`, and receives the host module with process-spawning APIs such as `execSync` and `spawn`.
The safe proof below only checks module and function reachability. It does not execute any OS command.
## Affected Mode
NodeVM.
## Affected Configuration
```js
new NodeVM({
require: {
builtin: ['*', '-node:child_process']
}
});
```
This affects users who deny builtins using their `node:`-prefixed spelling, expecting `-node:child_process` to deny `require('node:child_process')` and `require('child_process')`.
## Affected Files / Functions
- `lib/builtin.js`
- `makeBuiltinsFromLegacyOptions`
- wildcard builtin expansion
- exact negative entry check: `builtins.indexOf(\`-${name}\`)`
- `addDefaultBuiltin`
- `lib/resolver.js`
- `Resolver.resolve`
- `lib/setup-node-sandbox.js`
- `requireImpl`
- `node:` prefix stripping before builtin load
## Root Cause
`lib/setup-node-sandbox.js` strips the `node:` prefix from resolved builtin filenames before loading the builtin:
```js
if (localStringPrototypeStartsWith(filename, 'node:')) {
id = localStringPrototypeSlice(filename, 5);
let nmod = cacheBuiltins[id];
if (!nmod) {
nmod = loadBuiltinModule(id);
if (!nmod) throw new VMError(`Cannot find module '${filename}'`, 'ENOTFOUND');
cacheBuiltins[id] = nmod;
}
return nmod;
}
```
But `lib/builtin.js` checks wildcard negative entries by exact string match against the names in `BUILTIN_MODULES`:
```js
if (builtins.indexOf(`-${name}`) === -1) {
addDefaultBuiltin(res, name, hostRequire);
}
```
`BUILTIN_MODULES` contains the canonical name `child_process`, not `node:child_process`. Therefore `-node:child_process` does not exclude `child_process`, and `addDefaultBuiltin()` registers the host builtin.
## Security Boundary Crossed
Sandboxed code reaches a host builtin that the embedder attempted to deny.
Boundary crossed:
- sandbox -> host `child_process` builtin
- sandbox -> host process-spawning function references
## Impact
Confirmed impact:
- `require('child_process')` succeeds inside the sandbox.
- `require('node:child_process')` succeeds inside the sandbox.
- The returned module exposes `execSync` and `spawn` as functions.
Worst confirmed impact is access to host process-spawning APIs. The proof does not execute any command.
The proof does not execute a command, but it confirms access to the host child_process module and its process-spawning APIs. For untrusted sandbox code, this is equivalent to command execution capability.
## Safe Local Reproduction
Tested on Node.js `v24.14.0`.
This proof only checks whether the module and dangerous functions are reachable. It does not spawn a process and does not run OS commands.
```js
'use strict';
const { NodeVM } = require('./');
function probe(builtin) {
const vm = new NodeVM({
require: {
builtin
}
});
return vm.run(`
const out = {};
for (const spec of ['child_process', 'node:child_process']) {
try {
const cp = require(spec);
out[spec] = {
loaded: true,
execSyncType: typeof cp.execSync,
spawnType: typeof cp.spawn,
moduleToStringTag: Object.prototype.toString.call(cp)
};
} catch (e) {
out[spec] = {
loaded: false,
name: e && e.name,
code: e && e.code,
message: e && e.message
};
}
}
module.exports = out;
`);
}
console.log(JSON.stringify({
nodeVersion: process.version,
denyNodePrefixed: probe(['*', '-node:child_process']),
denyCanonical: probe(['*', '-child_process'])
}, null, 2));
```
Observed result:
```json
{
"nodeVersion": "v24.14.0",
"denyNodePrefixed": {
"child_process": {
"loaded": true,
"execSyncType": "function",
"spawnType": "function",
"moduleToStringTag": "[object Object]"
},
"node:child_process": {
"loaded": true,
"execSyncType": "function",
"spawnType": "function",
"moduleToStringTag": "[object Object]"
}
},
"denyCanonical": {
"child_process": {
"loaded": false,
"name": "VMError",
"code": "ENOTFOUND",
"message": "Cannot find module 'child_process'"
},
"node:child_process": {
"loaded": false,
"name": "VMError",
"code": "ENOTFOUND",
"message": "Cannot find module 'node:child_process'"
}
}
}
```
## Expected Secure Behavior
`-node:child_process` and `-child_process` should be equivalent.
If either spelling is denied, both of these should fail:
```js
require('child_process')
require('node:child_process')
```
## Suggested Fix
1. Canonicalize builtin names before allow/deny comparison:
- Strip `node:` from user-provided builtin entries.
- Preserve whether an entry is negative (`-...`) before canonicalizing.
- Store and compare one canonical builtin key.
2. Apply the same normalization to:
- wildcard negative entries
- explicit allowlist entries
- object-form builtin entries
- mock/override keys if they are intended to support `node:` spelling
3. Add regression tests:
- `builtin: ['*', '-node:child_process']` blocks `child_process`.
- `builtin: ['*', '-node:child_process']` blocks `node:child_process`.
- `builtin: ['*', '-node:fs']` blocks `fs` and `node:fs`.
- `builtin: ['*', '-node:fs/promises']` and `-fs/promises` behave consistently.
- Canonical dangerous builtins remain denied even if explicitly requested with `node:` spelling.
EPSS, exploit probability
Low0.54%
estimated chance of real-world exploitation in the next 30 days, higher than 43.1% of every CVE FIRST.org scores
Refreshed 10/1/2026, via FIRST.org's EPSS model, not CVSS, this measures likelihood of exploitation, not how severe it would be.