GHSA-7q3f-wx44-378m: Path Traversal
Summary
isPathAllowedForModule decides whether a resolved path belongs to an allowlisted external module using a raw string prefix test. nodemodules/foo2 starts with nodemodules/foo, so a package whose name merely shares a prefix with an allowlisted one is treated as being inside it, and a relative require from the allowlisted package reaches it even with transitive loading disabled.
Where it is
lib/resolver-compat.js, lines 122 to 132, quoted from HEAD 7a1f5100b96f48d34e0fe104ab37c0acc5944f92:
js isPathAllowedForModule(path, mod) { if (!super.isPathAllowed(path)) return false; if (mod) { if (mod.allowTransitive) return true; if (path.startsWith(mod.path)) { const rem = path.slice(mod.path.length); if (!/(?:^|[\\/])nodemodules(?:$|[\\/])/.test(rem)) return true; } } return this.externals.some(regex => regex.test(path)); }
With mod.path of .../nodemodules/foo and a resolved path of .../nodemodules/foo2/index.js, startsWith is true and rem is 2/index.js, which contains no nodemodules segment, so the function returns true.
The nodemodules test in rem is what stops a genuine transitive dependency from slipping through. It does not stop a sibling, because a sibling's remainder never contains that segment.
Impact
Code running in NodeVM under an external module allowlist with transitive: false can reach a package that was not allowlisted, provided an allowlisted package performs a relative require to a prefix-sharing sibling.
Two preconditions are worth stating plainly rather than leaving implicit. The deployment must already have such a package layout, and an allowlisted package must have a reachable code path that does the relative require. This is not something the attacker creates; it is something they find. That narrows it considerably, and it is why I have not scored it higher.
Reachability
NodeVM.run at lib/nodevm.js:506 executes the script. require comes from createRequireForModule at lib/setup-node-sandbox.js:168-172 and reaches the resolver callback at lib/nodevm.js:380-384. LegacyResolver.resolveFull at lib/resolver-compat.js:145-160 sets currMod for direct requires, the relative specifier resolves through DefaultResolver.resolveFull and tryFile at lib/resolver.js:327-330, and the authorization decision lands on the function above.
Suggested fix
Require a separator after the prefix, so a sibling cannot match:
js if (path === mod.path || path.startsWith(mod.path + path.sep)) {
That is the same anchoring the rem regex already applies to nodemodules, applied one level earlier.
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
npm/vm2to a version that resolves this vulnerability.Fixed in 3.11.7 - Compensating control
Update isPathAllowedForModule so the allowlisted module path prefix must be followed by a path separator: use a boundary check equivalent to path.startsWith(mod.path + path.sep), rather than the raw path.startsWith(mod.path) test, preventing sibling packages such as node_modules/foo2 from matching allowlisted node_modules/foo.
Event History
Frequently Asked Questions
Which configurations are exposed to this bypass?
Configurations that allowlist an external module and disable transitive loading are affected when another resolved package path shares the allowlisted module path as a raw prefix. For example, an allowlisted module at node_modules/foo can incorrectly allow code from node_modules/foo2.
What does an attacker need to exploit this behavior?
The resolved path must begin with the allowlisted module's path but refer to a different package whose name shares that prefix. A relative require originating from the allowlisted package can then reach that package even though transitive loading is disabled.
How can I identify a potentially affected setup?
Review external-module allowlists and compare each allowed module path or name with other installed package paths that begin with the same value. Pay particular attention to prefix pairs such as foo and foo2 where the second package is located alongside the allowlisted package under node_modules.