GHSA-7Q3F-WX44-378M
Vulnerability from github – Published: 2026-10-01 15:26 – Updated: 2026-10-01 15:26Summary
isPathAllowedForModule decides whether a resolved path belongs to an allowlisted external module using a raw string prefix test. node_modules/foo2 starts with node_modules/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:
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 (!/(?:^|[\\/])node_modules(?:$|[\\/])/.test(rem)) return true;
}
}
return this.externals.some(regex => regex.test(path));
}
With mod.path of .../node_modules/foo and a resolved path of .../node_modules/foo2/index.js, startsWith is true and rem is 2/index.js, which contains no node_modules segment, so the function returns true.
The node_modules 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:
if (path === mod.path || path.startsWith(mod.path + path.sep)) {
That is the same anchoring the rem regex already applies to node_modules, applied one level earlier.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 3.11.6"
},
"package": {
"ecosystem": "npm",
"name": "vm2"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.11.7"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-92945"
],
"database_specific": {
"cwe_ids": [
"CWE-22",
"CWE-863"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-01T15:26:45Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Summary\n\n`isPathAllowedForModule` decides whether a resolved path belongs to an allowlisted external module using a raw string prefix test. `node_modules/foo2` starts with `node_modules/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.\n\n## Where it is\n\n`lib/resolver-compat.js`, lines 122 to 132, quoted from HEAD `7a1f5100b96f48d34e0fe104ab37c0acc5944f92`:\n\n```js\nisPathAllowedForModule(path, mod) {\n if (!super.isPathAllowed(path)) return false;\n if (mod) {\n if (mod.allowTransitive) return true;\n if (path.startsWith(mod.path)) {\n const rem = path.slice(mod.path.length);\n if (!/(?:^|[\\\\/])node_modules(?:$|[\\\\/])/.test(rem)) return true;\n }\n }\n return this.externals.some(regex =\u003e regex.test(path));\n}\n```\n\nWith `mod.path` of `.../node_modules/foo` and a resolved path of `.../node_modules/foo2/index.js`, `startsWith` is true and `rem` is `2/index.js`, which contains no `node_modules` segment, so the function returns true.\n\nThe `node_modules` test in `rem` is what stops a genuine transitive dependency from slipping through. It does not stop a sibling, because a sibling\u0027s remainder never contains that segment.\n\n## Impact\n\nCode 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.\n\nTwo 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.\n\n## Reachability\n\n`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.\n\n## Suggested fix\n\nRequire a separator after the prefix, so a sibling cannot match:\n\n```js\nif (path === mod.path || path.startsWith(mod.path + path.sep)) {\n```\n\nThat is the same anchoring the `rem` regex already applies to `node_modules`, applied one level earlier.",
"id": "GHSA-7q3f-wx44-378m",
"modified": "2026-10-01T15:26:45Z",
"published": "2026-10-01T15:26:45Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/patriksimek/vm2/security/advisories/GHSA-7q3f-wx44-378m"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-92945"
},
{
"type": "WEB",
"url": "https://github.com/patriksimek/vm2/commit/6ac3916da84e060c403e407b6b6318fcc66b0e72"
},
{
"type": "PACKAGE",
"url": "https://github.com/patriksimek/vm2"
},
{
"type": "WEB",
"url": "https://github.com/patriksimek/vm2/releases/tag/v3.11.7"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/vm2-before-3.11.7-module-allowlist-bypass-via-prefix-matching"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "vm2: External module allowlist uses a raw prefix test, so a prefix-sharing sibling package is treated as allowlisted"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.