{"id":"GHSA-c59q-g84q-2gj5","summary":"pnpm: Virtual store linker path traversal via unvalidated depPath name in lockfileToDepGraph","details":"## Summary\n\nThe 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`.\n\nThis 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`.\n\n## Details\n\n### Root Cause\n\n`dp.parse()` at `pnpm11/deps/path/src/index.ts:135` extracts the package name as:\n```typescript\nconst name = dependencyPath.substring(0, sepIndex)\n```\n\nThis 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'`.\n\n### Vulnerable Code Path\n\n1. `pnpm-lock.yaml` → `lockfile.packages['../../../../../../../tmp/pwned@1.0.0']` (attacker-controlled lockfile key)\n2. `nameVerFromPkgSnapshot(depPath, pkgSnapshot)` at `lockfile/utils/src/nameVerFromPkgSnapshot.ts:16` → calls `dp.parse(depPath)` → returns `{ name: '../../../../../../../tmp/pwned' }`\n3. `lockfileToDepGraph.ts:232` → `modules = path.join(dirInVirtualStore, 'node_modules')`\n4. `lockfileToDepGraph.ts:233` → `dir = path.join(modules, pkgName)` → resolves to `/tmp/pwned` (ESCAPES virtual store)\n5. `storeController.importPackage(depNode.dir, ...)` → writes package content to the traversed path\n\n### Why Existing Defenses Don't Catch It\n\n- **`depPathToFilename()`** — replaces `/` with `+` for the `dirInVirtualStore` path, but `pkgName` comes SEPARATELY from `dp.parse()` and is NOT passed through this function\n- **`verifyLockfileResolutions()`** — validates dependency map keys (aliases) via `isValidDependencyAlias()`, but never validates the depPath keys themselves\n- **Lockfile parser** — `yaml.load(lockfileRawContent)` with no schema validation on `packages` keys\n- **`importPackage()`** — accepts `targetDir` and passes it directly to `cafsStore.importPackage(targetDir, ...)` with zero containment check\n- **Integrity verification** — requires a real fetchable package but does not validate the destination path\n\n### Escalation to RCE (non-default config)\n\nWhen `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.\n\n### Also Affected (PnP linker)\n\nWhen `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).\n\n## Impact\n\nAn 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).\n\nTargets for arbitrary file write include:\n- `.git/hooks/pre-commit` — code execution on next git operation\n- `~/.local/bin/` — binary hijacking\n- Project source files — supply chain injection\n\n## Reproduction\n\nCraft a `pnpm-lock.yaml`:\n```yaml\nlockfileVersion: '9.0'\npackages:\n  ../../../../../../../tmp/pwned@1.0.0:\n    resolution: {integrity: sha512-\u003creal-package-integrity\u003e}\n    engines: {node: '\u003e=14'}\nsnapshots:\n  ../../../../../../../tmp/pwned@1.0.0: {}\nimporters:\n  .:\n    dependencies:\n      legitimate-name:\n        specifier: ^1.0.0\n        version: ../../../../../../../tmp/pwned@1.0.0\n```\n\nRun `pnpm install` — package content is written to `/tmp/pwned/` instead of the virtual store.\n\n## Recommended Fix\n\nApply `safeJoinModulesDir` (or equivalent validation) at:\n- `lockfileToDepGraph.ts:233` — `path.join(modules, pkgName)`\n- `after-install/src/index.ts:402` — `path.join(pkgModulesDir(depPath), pkgInfo.name)`\n- `lockfile/to-pnp/src/index.ts:105-110` — PnP `packageLocation`\n\nAlternatively, validate depPath keys during lockfile parsing to reject any that don't produce valid npm package names via `dp.parse()`.\n\n## Relationship to GHSA-fr4h-3cph-29xv\n\nGHSA-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`.","aliases":["CVE-2026-82392"],"modified":"2026-09-02T14:45:06.876655826Z","published":"2026-09-02T14:37:13Z","database_specific":{"nvd_published_at":"2026-08-31T21:17:54Z","cwe_ids":["CWE-22"],"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-09-02T14:37:13Z"},"references":[{"type":"WEB","url":"https://github.com/pnpm/pnpm/security/advisories/GHSA-c59q-g84q-2gj5"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-82392"},{"type":"WEB","url":"https://github.com/pnpm/pnpm/pull/12872"},{"type":"WEB","url":"https://github.com/pnpm/pnpm/pull/12890"},{"type":"WEB","url":"https://github.com/pnpm/pnpm/commit/51300fd41c5e4c8f47635108e373cc3d1f324fa7"},{"type":"WEB","url":"https://github.com/pnpm/pnpm/commit/78e29fe5583a1e5d69ea05e414eff310f78d5ed9"},{"type":"PACKAGE","url":"https://github.com/pnpm/pnpm"},{"type":"WEB","url":"https://github.com/pnpm/pnpm/releases/tag/v10.34.5"},{"type":"WEB","url":"https://github.com/pnpm/pnpm/releases/tag/v11.11.0"}],"affected":[{"package":{"name":"pnpm","ecosystem":"npm","purl":"pkg:npm/pnpm"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"10.34.5"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-c59q-g84q-2gj5/GHSA-c59q-g84q-2gj5.json"}},{"package":{"name":"pnpm","ecosystem":"npm","purl":"pkg:npm/pnpm"},"ranges":[{"type":"SEMVER","events":[{"introduced":"11.0.0"},{"fixed":"11.11.0"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-c59q-g84q-2gj5/GHSA-c59q-g84q-2gj5.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:L"}]}