CVE-2026-82392: pnpm: Virtual store linker path traversal via unvalidated depPath name in lockfileToDepGraph
Summary
The virtual store linker constructs package installation directories using path.join(modules, pkgName) where pkgName is extracted from lockfile packages keys via dp.parse(depPath).name without validation. A crafted pnpm-lock.yaml with traversal sequences in depPath keys (e.g., ../../../tmp/pwned@1.0.0) causes package content to be written to arbitrary filesystem paths during pnpm install.
This is an incomplete fix of GHSA-fr4h-3cph-29xv — the safeJoinModulesDir containment helper was applied to the hoisted linker and symlinkDependency but NOT to the virtual store linker's lockfileToDepGraph.ts:233.
Details
Root Cause
dp.parse() at pnpm11/deps/path/src/index.ts:135 extracts the package name as: typescript const name = dependencyPath.substring(0, sepIndex)
This is a raw substring operation with zero validation that name is a valid npm package name. A depPath of ../../../tmp/pwned@1.0.0 yields name = '../../../tmp/pwned'.
Vulnerable Code Path
1. pnpm-lock.yaml → lockfile.packages['../../../../../../../tmp/pwned@1.0.0'] (attacker-controlled lockfile key) 2. nameVerFromPkgSnapshot(depPath, pkgSnapshot) at lockfile/utils/src/nameVerFromPkgSnapshot.ts:16 → calls dp.parse(depPath) → returns { name: '../../../../../../../tmp/pwned' } 3. lockfileToDepGraph.ts:232 → modules = path.join(dirInVirtualStore, 'nodemodules') 4. lockfileToDepGraph.ts:233 → dir = path.join(modules, pkgName) → resolves to /tmp/pwned (ESCAPES virtual store) 5. storeController.importPackage(depNode.dir, ...) → writes package content to the traversed path
Why Existing Defenses Don't Catch It
- depPathToFilename() — replaces / with + for the dirInVirtualStore path, but pkgName comes SEPARATELY from dp.parse() and is NOT passed through this function - verifyLockfileResolutions() — validates dependency map keys (aliases) via isValidDependencyAlias(), but never validates the depPath keys themselves - Lockfile parser — yaml.load(lockfileRawContent) with no schema validation on packages keys - importPackage() — accepts targetDir and passes it directly to cafsStore.importPackage(targetDir, ...) with zero containment check - Integrity verification — requires a real fetchable package but does not validate the destination path
Escalation to RCE (non-default config)
When dangerouslyAllowAllBuilds: true is configured (or the traversal package name is in the explicit allowBuilds list), the same traversed path is used in the rebuild phase at after-install/src/index.ts:402,470. The attacker's postinstall script then executes with the victim's shell access. Under default config, allowBuild returns false for unknown packages, limiting impact to arbitrary file write.
Also Affected (PnP linker)
When nodeLinker: pnp is configured, lockfileToPackageRegistry() at lockfile/to-pnp/src/index.ts:105-110 uses the same unvalidated dp.parse().name in packageLocation construction, allowing the .pnp.cjs resolver map to point outside the virtual store. This is a lower-impact variant (PnP is not the default linker).
Impact
An attacker who can commit a crafted pnpm-lock.yaml to a repository (or supply one via a malicious package) can cause arbitrary file writes on the machine of any user who runs pnpm install. Written content is the actual package files from a real npm package (attacker controls which package and which destination).
Targets for arbitrary file write include: - .git/hooks/pre-commit — code execution on next git operation - ~/.local/bin/ — binary hijacking - Project source files — supply chain injection
Reproduction
Craft a pnpm-lock.yaml: yaml lockfileVersion: '9.0' packages: ../../../../../../../tmp/pwned@1.0.0: resolution: {integrity: sha512-<real-package-integrity>} engines: {node: '>=14'} snapshots: ../../../../../../../tmp/pwned@1.0.0: {} importers: .: dependencies: legitimate-name: specifier: ^1.0.0 version: ../../../../../../../tmp/pwned@1.0.0
Run pnpm install — package content is written to /tmp/pwned/ instead of the virtual store.
Recommended Fix
Apply safeJoinModulesDir (or equivalent validation) at: - lockfileToDepGraph.ts:233 — path.join(modules, pkgName) - after-install/src/index.ts:402 — path.join(pkgModulesDir(depPath), pkgInfo.name) - lockfile/to-pnp/src/index.ts:105-110 — PnP packageLocation
Alternatively, validate depPath keys during lockfile parsing to reject any that don't produce valid npm package names via dp.parse().
Relationship to GHSA-fr4h-3cph-29xv
GHSA-fr4h-3cph-29xv fixed the hoisted linker path (lockfileToHoistedDepGraph.ts:222) by adding safeJoinModulesDir. The same fix was NOT applied to the virtual store linker, which uses the identical dp.parse().name → path.join() pattern at lockfileToDepGraph.ts:233.
Other sources
pnpm is a package manager. Prior to 10.34.5 and from 11.0.0 until 11.11.0, pnpm parses the package name from attacker-controlled pnpm-lock.yaml packages keys with dp.parse(depPath).name and uses it without validation in deps/graph-builder/src/lockfileToDepGraph.ts and pnpm11/deps/graph-builder/src/lockfileToDepGraph.ts. The name reaches path.join(modules, pkgName), storeController.importPackage, and pnpm11/lockfile/to-pnp/src/index.ts, allowing package contents to be written outside nodemodules when a user runs pnpm install. When dangerouslyAllowAllBuilds or a matching allowBuilds entry permits lifecycle scripts, the escaped package can execute code with the user's privileges. This issue is fixed in versions 10.34.5 and 11.11.0.
— NVD
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
npm/pnpmto a version that resolves this vulnerability.Fixed in 11.11.0 - Upgrade
Upgrade
npm/pnpmto a version that resolves this vulnerability.Fixed in 10.34.5 - Upgrade
Upgrade
pnpmto a version that resolves this vulnerability.Fixed in 10.34.5 - Upgrade
Upgrade
pnpmto a version that resolves this vulnerability.Fixed in 11.11.0 - Configuration
Ensure dangerouslyAllowAllBuilds is not enabled (set to false) so escaped package traversal does not reach the rebuild/lifecycle execution paths during pnpm install.
pnpm dangerouslyAllowAllBuilds = false - Configuration
Do not include the traversal package name (from the crafted pnpm-lock.yaml depPath) in pnpm's explicit allowBuilds list, so lifecycle/build scripts are not executed with user privileges.
pnpm allowBuilds = remove traversal package from allowBuilds list
Event History
Frequently Asked Questions
Which installations need to be remediated?
pnpm versions before 10.34.5 are affected, as are versions from 11.0.0 through 11.11.0. Upgrade to 10.34.5 or 11.11.0, as applicable.
What must an attacker provide or persuade a user to do?
The attacker needs to control a package key in pnpm-lock.yaml such that its depPath name is malicious. Exploitation occurs when a user runs pnpm install against that lockfile.
Does exploitation automatically result in code execution?
The traversal can write package contents outside node_modules during installation. Code execution with the user's privileges requires lifecycle scripts to be permitted through dangerouslyAllowAllBuilds or a matching allowBuilds entry.
What can be done if an upgrade is not immediately possible?
Avoid running pnpm install with untrusted or attacker-controlled pnpm-lock.yaml files. Do not enable dangerouslyAllowAllBuilds, and do not add allowBuilds entries for packages that may be supplied through an untrusted lockfile.