Skip to content

fix(security): backport path-traversal and containment fixes to v10#12504

Merged
zkochan merged 1 commit into
release/10from
backport/v10-security-path-traversal-fixes
Jun 18, 2026
Merged

fix(security): backport path-traversal and containment fixes to v10#12504
zkochan merged 1 commit into
release/10from
backport/v10-security-path-traversal-fixes

Conversation

@zkochan

@zkochan zkochan commented Jun 18, 2026

Copy link
Copy Markdown
Member

Summary

Backports three merged security fixes from main to release/10. Each keeps the original fix's behavior but is adapted to v10's package layout (no pacquet, no env-lockfile, no verifyLockfileResolutions gate).

1. Contain hoisted dependency aliases — GHSA-fr4h-3cph-29xv (port of #12343)

On a frozen/up-to-date lockfile the nodeLinker: hoisted restore skips resolution, so the alias validation added in #11954 never ran. A crafted lockfile alias (../../../escape) could be joined directly under a hoisted node_modules directory, and reserved aliases (.bin, .pnpm, node_modules) could overwrite pnpm-owned layout.

  • fs/symlink-dependency/safeJoinModulesDir.ts now rejects aliases that are not valid npm package names (path-traversal, absolute, reserved) via validate-npm-package-name, in addition to its existing containment check, and is exported.
  • The hoisted-graph dep.name sink in pkg-manager/headless/lockfileToHoistedDepGraph.ts now goes through safeJoinModulesDir instead of a raw path.join.

v10 has no verifyLockfileResolutions, so the upstream layer-2 verifier gate does not apply; the sink guard (which both the isolated and hoisted linkers route through) closes the exploit.

2. Contain patch-remove deletions (port of #12341)

pnpm patch-remove now validates the configured patches directory and the full removal batch (canonicalizing parents, rejecting directory entries and nested-symlink escapes, unlinking final symlinks without following them) before unlinking any patch file, so it can't delete files outside the configured patches directory.

3. Reject path-traversal config dependency names/versions — GHSA-qrv3-253h-g69c (port of #12470)

Config dependency names and versions from configDependencies in pnpm-workspace.yaml become path segments of node_modules/.pnpm-config/<name> and the store's <name>/<version>/<hash>, but were used unvalidated. config/deps-installer/normalizeConfigDeps.ts (the install-boundary chokepoint, before any path is built) now requires names to be valid npm package names and versions to be exact semver. __proto__ is rejected (leading _). v10 has no env lockfile / optional config-subdeps, so the validation is scoped to the workspace-manifest config deps.

Tests

  • @pnpm/symlink-dependency: helper tests extended with reserved-name aliases + valid controls (24 passed).
  • @pnpm/headless: new lockfileToHoistedDepGraph exploit-regression test — traversal/reserved aliases rejected before any fetch or filesystem work (6 passed).
  • @pnpm/plugin-commands-patching: isSubdirectory + patchRemove containment tests (9 passed); existing patch.test.ts still green (34 passed).
  • @pnpm/config.deps-installer: traversal-name, __proto__, and traversal-version regression tests (12 passed incl. existing).
  • Root eslint, lint:meta (meta-updater), and cspell all clean.

One note for reviewers: the hoisted-graph regression test imports the built ../lib output instead of ../src because ts-jest cannot transitively resolve @yarnpkg/nm's HoisterResult type under moduleResolution: node; skipLibCheck covers the .d.ts. The package's test script compiles first, so CI is unaffected.


Written by an agent (Claude Code, claude-opus-4-8).

Backports three merged security fixes from main to release/10:

- Contain hoisted dependency aliases so a crafted lockfile alias cannot
  escape node_modules or overwrite pnpm-owned layout. The hoisted graph
  builder now validates each alias at the safeJoinModulesDir sink.
  (GHSA-fr4h-3cph-29xv, #12343)
- Contain pnpm patch-remove deletions to the configured patches
  directory. (#12341)
- Reject path-traversal config dependency names and versions from
  pnpm-workspace.yaml before they are used to build filesystem paths.
  (GHSA-qrv3-253h-g69c, #12470)

Written by an agent (Claude Code, claude-opus-4-8).
@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 71c2dfce-a500-448a-8d10-adc4e78ff987

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch backport/v10-security-path-traversal-fixes

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

CI Feedback 🧐

A test triggered by this PR failed. Here is an AI-generated analysis of the failure:

Action: Audit dependencies

Failed stage: Audit [❌]

Failed test name: ""

Failure summary:

The action failed because a vulnerability scan (likely npm audit/security audit step) found 7
vulnerabilities in dependencies and returned a non-zero exit code.
- The log shows vulnerabilities
including @babel/core (vulnerable versions <=7.29.0, patched >=7.29.6), and a total of 7 vulnerabilities
found with severities 1 low | 5 moderate (1 ignored) | 1 high.
- The workflow treated these findings
as a failure condition, ending with Process completed with exit code 1.

Relevant error logs:
1:  ##[group]Runner Image Provisioner
2:  Hosted Compute Agent
...

192:  │ low                 │ @babel/core: Arbitrary File Read via sourceMappingURL  │
193:  │                     │ Comment                                                │
194:  ├─────────────────────┼────────────────────────────────────────────────────────┤
195:  │ Package             │ @babel/core                                            │
196:  ├─────────────────────┼────────────────────────────────────────────────────────┤
197:  │ Vulnerable versions │ <=7.29.0                                               │
198:  ├─────────────────────┼────────────────────────────────────────────────────────┤
199:  │ Patched versions    │ >=7.29.6                                               │
200:  ├─────────────────────┼────────────────────────────────────────────────────────┤
201:  │ Paths               │ .>@babel/core                                          │
202:  ├─────────────────────┼────────────────────────────────────────────────────────┤
203:  │ More info           │ https://github.com/advisories/GHSA-4x5r-pxfx-6jf8      │
204:  └─────────────────────┴────────────────────────────────────────────────────────┘
205:  7 vulnerabilities found
206:  Severity: 1 low | 5 moderate (1 ignored) | 1 high
207:  ##[error]Process completed with exit code 1.
208:  Post job cleanup.

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Backport v10 security fixes for traversal-safe installs and patch removal
🐞 Bug fix 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

Description

• Validate lockfile-provided hoisted aliases to prevent traversal and reserved-name clobbering.
• Harden pnpm patch-remove to only unlink files contained within the configured patches directory.
• Validate workspace configDependencies names and versions before building any filesystem paths.
Diagram

graph TD
  W[/"pnpm-workspace.yaml"/] --> C["@pnpm/config.deps-installer"] --> V["Path validation utils"] --> S[("pnpm store")]
  L[/"pnpm-lock.yaml"/] --> H["@pnpm/headless hoisted graph"] --> V --> F["Project FS (node_modules, patches)"]
  P["pnpm patch-remove"] --> V --> F
  subgraph Legend
    direction LR
    _in[/"Input config/lockfile"/] ~~~ _mod["Module/command"] ~~~ _db[("On-disk storage")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Lockfile-wide verifier gate for hoisted installs
  • ➕ Centralizes policy enforcement and can report all violations at once
  • ➕ Keeps graph-building code simpler by separating validation from execution
  • ➖ v10 lacks upstream gating hooks (as noted), increasing backport complexity
  • ➖ Easy to miss secondary sinks unless every consumer routes through the verifier
2. Generic path-segment sanitizer (not npm-name based)
  • ➕ Can be reused for non-package-name segments beyond aliases/config deps
  • ➕ Potentially fewer new dependencies (no validate-npm-package-name)
  • ➖ Harder to precisely capture npm’s reserved/legacy name rules
  • ➖ Risk of allowing visually safe but semantically dangerous names (e.g., reserved layout names)
3. Filesystem-only containment via realpath on all joins
  • ➕ Robust against some normalization quirks and platform-specific path behavior
  • ➕ Doesn’t require semantic validation of the string
  • ➖ Requires filesystem access (not always available at validation time)
  • ➖ Realpath can follow symlinks and needs careful handling to avoid TOCTOU issues

Recommendation: The PR’s approach (validate at the path-construction/unlink “sinks” with npm-name/semver rules plus containment checks) is the best fit for a v10 backport: it protects both lockfile-driven and config-driven flows before any filesystem effects occur, and it keeps the security boundary close to the dangerous operations. The added safeJoinModulesDir export and targeted chokepoints (normalizeConfigDeps, hoisted graph join, patch-remove batch validation) make it less likely a new caller bypasses protections.

Files changed (19) +635 / -25

Enhancement (2) +18 / -0
index.tsExport safeJoinModulesDir helper +1/-0

Export safeJoinModulesDir helper

• Exports 'safeJoinModulesDir' for reuse by other packages (notably headless hoisted graph building) as a common safe path sink.

fs/symlink-dependency/src/index.ts

isSubdirectory.tsAdd reusable subdirectory containment helper +17/-0

Add reusable subdirectory containment helper

• Introduces 'isSubdirectory' to robustly determine whether a resolved path is within a parent directory, including Windows escape considerations.

patching/plugin-commands-patching/src/isSubdirectory.ts

Bug fix (6) +197 / -20
assertValidConfigDepName.tsIntroduce config dependency name validator +20/-0

Introduce config dependency name validator

• Adds 'assertValidConfigDepName' to reject invalid/reserved/traversal-like names (including '__proto__') before any install-time paths are built.

config/deps-installer/src/assertValidConfigDepName.ts

assertValidConfigDepVersion.tsIntroduce config dependency version validator +15/-0

Introduce config dependency version validator

• Adds 'assertValidConfigDepVersion' to require exact semver versions so versions cannot be used as traversal-shaped store path segments.

config/deps-installer/src/assertValidConfigDepVersion.ts

normalizeConfigDeps.tsValidate configDependencies before normalization +7/-0

Validate configDependencies before normalization

• Calls the new name/version assertions during normalization so invalid inputs fail early, before registry/store paths are derived.

config/deps-installer/src/normalizeConfigDeps.ts

safeJoinModulesDir.tsReject invalid/reserved aliases before joining under node_modules +27/-9

Reject invalid/reserved aliases before joining under node_modules

• Adds npm package-name validation to 'safeJoinModulesDir' and improves error construction while retaining the existing resolved-path containment check.

fs/symlink-dependency/src/safeJoinModulesDir.ts

patchRemove.tsContain patch-remove unlinking to the patches directory +126/-10

Contain patch-remove unlinking to the patches directory

• Refactors removal into a validated batch that rejects traversal, directory entries, and nested symlink escapes, and unlinks patch files without following symlinks before updating config and re-installing.

patching/plugin-commands-patching/src/patchRemove.ts

lockfileToHoistedDepGraph.tsUse safeJoinModulesDir for hoisted alias directories +2/-1

Use safeJoinModulesDir for hoisted alias directories

• Routes the hoisted 'dep.name' directory construction through 'safeJoinModulesDir' instead of raw 'path.join' to block traversal/reserved aliases from lockfiles.

pkg-manager/headless/src/lockfileToHoistedDepGraph.ts

Tests (5) +371 / -1
installConfigDeps.tsAdd regression tests for traversal name/version and __proto__ +76/-0

Add regression tests for traversal name/version and proto

• Extends install tests to assert invalid config dependency names/versions are rejected and do not create sentinel files in the project or store directories.

config/deps-installer/test/installConfigDeps.ts

safeJoinModulesDir.test.tsTest reserved alias rejections and valid alias acceptance +24/-1

Test reserved alias rejections and valid alias acceptance

• Expands invalid alias coverage to include '.bin', '.pnpm', and 'node_modules', and adds positive cases for valid package-name aliases.

fs/symlink-dependency/test/safeJoinModulesDir.test.ts

isSubdirectory.test.tsAdd unit tests for isSubdirectory (POSIX and Windows) +19/-0

Add unit tests for isSubdirectory (POSIX and Windows)

• Covers accepted in-tree paths and rejected traversal/sibling/drive/UNC escape cases using 'path.win32' where relevant.

patching/plugin-commands-patching/test/isSubdirectory.test.ts

patchRemove.test.tsAdd patch-remove containment and symlink regression tests +166/-0

Add patch-remove containment and symlink regression tests

• Verifies patch-remove rejects unsafe targets before deletion, handles directory targets, avoids nested-symlink escapes, unlinks final symlinks without touching targets, and treats missing files as no-ops.

patching/plugin-commands-patching/test/patchRemove.test.ts

lockfileToHoistedDepGraph.test.tsAdd exploit-regression tests for hoisted alias validation +86/-0

Add exploit-regression tests for hoisted alias validation

• Adds tests ensuring crafted lockfiles with traversal/reserved aliases are rejected before any store fetch/filesystem work and do not create escaped paths.

pkg-manager/headless/test/lockfileToHoistedDepGraph.test.ts

Documentation (3) +21 / -0
config-deps-path-traversal.mdAdd changeset documenting configDependencies traversal fix +6/-0

Add changeset documenting configDependencies traversal fix

• Introduces a release note describing validation of config dependency names and versions to prevent path traversal into node_modules/store paths.

.changeset/config-deps-path-traversal.md

contain-hoisted-dependency-aliases.mdAdd changeset for hoisted alias containment hardening +9/-0

Add changeset for hoisted alias containment hardening

• Documents rejection of traversal-shaped and reserved hoisted aliases originating from lockfiles and the use of 'safeJoinModulesDir' as the enforcement point.

.changeset/contain-hoisted-dependency-aliases.md

fix-patch-remove-containment.mdAdd changeset for patch-remove containment fix +6/-0

Add changeset for patch-remove containment fix

• Adds a release note stating that 'pnpm patch-remove' is prevented from deleting files outside the configured patches directory.

.changeset/fix-patch-remove-containment.md

Other (3) +28 / -4
package.jsonAdd semver and npm-name validation dependencies +5/-1

Add semver and npm-name validation dependencies

• Adds 'semver' and 'validate-npm-package-name' plus corresponding type packages to support config dependency name/version validation.

config/deps-installer/package.json

package.jsonAdd validate-npm-package-name dependency for alias checking +4/-2

Add validate-npm-package-name dependency for alias checking

• Adds 'validate-npm-package-name' (and types) so symlink placement can enforce npm-valid aliases beyond simple containment checks.

fs/symlink-dependency/package.json

pnpm-lock.yamlLockfile updates for new validation dependencies +19/-1

Lockfile updates for new validation dependencies

• Updates the workspace lockfile to include 'semver', 'validate-npm-package-name', and related type packages used by the new validation logic.

pnpm-lock.yaml

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 18, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Informational

1. Misleading directory guarantee comment 🐞 Bug ⚙ Maintainability ⭐ New
Description
The safeJoinModulesDir() doc comment claims the joined alias path stays a “direct child” of
modulesDir, but the function intentionally accepts scoped names like "@scope/name" which create
nested directories under node_modules. This mismatch can mislead future changes/reviewers about what
the function actually guarantees (containment, not depth).
Code

fs/symlink-dependency/src/safeJoinModulesDir.ts[R5-8]

+// Joins `modulesDir` with a dependency alias and guarantees the result
+// stays a direct child of `modulesDir`. The alias becomes a directory
+// name inside `node_modules`, so it must be a valid npm package name: a
+// single `name` or `@scope/name` of URL-friendly characters with no
Evidence
The implementation comment states “direct child”, but tests explicitly treat @scope/name as a
valid alias, which produces a nested path under modulesDir (modulesDir/@scope/name) rather than
a direct child directory.

fs/symlink-dependency/src/safeJoinModulesDir.ts[5-14]
fs/symlink-dependency/test/safeJoinModulesDir.test.ts[48-58]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`safeJoinModulesDir()` documentation says the result stays a *direct child* of `modulesDir`, but the implementation allows scoped package aliases (e.g. `@scope/name`), which are necessarily nested under `node_modules/@scope/name`.

### Issue Context
This is purely a comment/documentation mismatch (runtime behavior is correct), but the function is now used as a security boundary and is exported, so accurate guarantees matter.

### Fix Focus Areas
- fs/symlink-dependency/src/safeJoinModulesDir.ts[5-13]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Semver check not strict 🐞 Bug ≡ Correctness
Description
assertValidConfigDepVersion() claims config dependency versions must be “an exact semver version”
but only checks semver.valid(version) != null, and normalizeConfigDeps() continues using the
original (non-canonical) version string for tarball/ID construction. This can produce inconsistent
version strings compared to other pnpm code that uses the canonicalized semver.valid() return value
when it requires exact versions.
Code

config/deps-installer/src/assertValidConfigDepVersion.ts[R7-9]

+export function assertValidConfigDepVersion (name: string, version: string): void {
+  if (semver.valid(version) == null) {
+    throw new PnpmError(
Evidence
The new validator only checks non-null return from semver.valid, while normalizeConfigDeps uses the
unmodified extracted version to build resolution/tarball fields. Elsewhere in the repo,
semver.valid’s return value is treated as the canonical version string, indicating a mismatch
between the “exact version” claim and this implementation.

config/deps-installer/src/assertValidConfigDepVersion.ts[7-14]
config/deps-installer/src/normalizeConfigDeps.ts[20-50]
config/version-policy/src/index.ts[80-89]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`assertValidConfigDepVersion()` currently validates with `semver.valid(version) != null` but does not enforce that the provided string is already in canonical/exact semver form, despite the error hint stating “must be an exact semver version”. Additionally, callers continue using the raw `version` string.
### Issue Context
Other parts of the repo treat `semver.valid()` as a canonicalizer by using its return value, which suggests the intent for “exact versions” is to work with normalized strings.
### Fix Focus Areas
- config/deps-installer/src/assertValidConfigDepVersion.ts[7-14]
- config/deps-installer/src/normalizeConfigDeps.ts[20-50]
### Suggested fix approach
- Option A (strict): change validation to require `semver.valid(version) === version` (and throw otherwise) so the error message matches behavior.
- Option B (normalize): change the validator to return the canonical version string (e.g. `const v = semver.valid(version)!`) and update `normalizeConfigDeps()` to use that returned value for `deps[pkgName].version` and for `getNpmTarballUrl()` / package IDs.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Previous review results

Review updated until commit 9c9ff42

Results up to commit 9c9ff42


🐞 Bugs (1) 📘 Rule violations (0) 📜 Skill insights (0)


Informational
1. Semver check not strict 🐞 Bug ≡ Correctness
Description
assertValidConfigDepVersion() claims config dependency versions must be “an exact semver version”
but only checks semver.valid(version) != null, and normalizeConfigDeps() continues using the
original (non-canonical) version string for tarball/ID construction. This can produce inconsistent
version strings compared to other pnpm code that uses the canonicalized semver.valid() return value
when it requires exact versions.
Code

config/deps-installer/src/assertValidConfigDepVersion.ts[R7-9]

+export function assertValidConfigDepVersion (name: string, version: string): void {
+  if (semver.valid(version) == null) {
+    throw new PnpmError(
Evidence
The new validator only checks non-null return from semver.valid, while normalizeConfigDeps uses the
unmodified extracted version to build resolution/tarball fields. Elsewhere in the repo,
semver.valid’s return value is treated as the canonical version string, indicating a mismatch
between the “exact version” claim and this implementation.

config/deps-installer/src/assertValidConfigDepVersion.ts[7-14]
config/deps-installer/src/normalizeConfigDeps.ts[20-50]
config/version-policy/src/index.ts[80-89]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`assertValidConfigDepVersion()` currently validates with `semver.valid(version) != null` but does not enforce that the provided string is already in canonical/exact semver form, despite the error hint stating “must be an exact semver version”. Additionally, callers continue using the raw `version` string.

### Issue Context
Other parts of the repo treat `semver.valid()` as a canonicalizer by using its return value, which suggests the intent for “exact versions” is to work with normalized strings.

### Fix Focus Areas
- config/deps-installer/src/assertValidConfigDepVersion.ts[7-14]
- config/deps-installer/src/normalizeConfigDeps.ts[20-50]

### Suggested fix approach
- Option A (strict): change validation to require `semver.valid(version) === version` (and throw otherwise) so the error message matches behavior.
- Option B (normalize): change the validator to return the canonical version string (e.g. `const v = semver.valid(version)!`) and update `normalizeConfigDeps()` to use that returned value for `deps[pkgName].version` and for `getNpmTarballUrl()` / package IDs.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Qodo Logo

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 9c9ff42

@zkochan zkochan merged commit 352ae48 into release/10 Jun 18, 2026
10 of 14 checks passed
@zkochan zkochan deleted the backport/v10-security-path-traversal-fixes branch June 18, 2026 19:54
@zkochan

zkochan commented Jun 22, 2026

Copy link
Copy Markdown
Member Author

🚢 10.34.4

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant