GHSA-FR4H-3CPH-29XV: Root-Cause Fix for pnpm Hoisted Alias and Config Dependency Path Traversal
Summary
The patch addresses the vulnerable path construction points rather than only filtering one input source. It adds npm-package-name validation for config dependency names and hoisted aliases, exact semver validation for config dependency versions, and preserves a containment check at the filesystem sink. This materially closes both traversal outside install roots and directory hijacking of pnpm-owned paths in hoisted mode, as described in the advisory.
Analysis
Vulnerability
GHSA-FR4H-3CPH-29XV describes path traversal and directory hijacking in pnpm and pacquet dependency resolution. The issue is rooted in untrusted dependency identifiers being reused as filesystem path segments during installation. In the config dependency flow, both the package name and version were used to construct directories under node_modules/.pnpm-config and store paths without strict validation. The patch notes explicitly call out traversal-shaped names such as ../../PWNED and versions such as ../../../PWNED as previously capable of causing writes or symlink creation outside the intended roots.
In hoisted mode, the risk also existed at the alias sink used to place dependencies under a hoisted node_modules tree. The changeset states that a crafted lockfile alias could be joined directly under the hoisted modules directory, enabling writes outside the install root or overwriting pnpm-owned layout such as .bin, .pnpm, or node_modules itself. This is significant because lockfile-derived aliases bypass assumptions that manifest-time validation already occurred. The advisory and commit together show two related classes of bug: traversal outside the target directory and in-tree directory hijacking of reserved pnpm paths via attacker-controlled names or aliases.
Sources: official patch commit, GitHub Security Advisory, report summary.
const configDeps: Record<string, string> = {
'../../PWNED': `100.0.0+${getIntegrity('@pnpm.e2e/foo', '100.0.0')}`,
}
const configDeps2: Record<string, string> = {
'@pnpm.e2e/foo': `../../../PWNED+${getIntegrity('@pnpm.e2e/foo', '100.0.0')}`,
}Patch
The patch in commit 352ae489f1b14ffdc19d2c6eacb1b06b098c2ddc hardens the vulnerable path-building flows at multiple points.
First, config dependency names are now validated with validate-npm-package-name in assertValidConfigDepName.ts. The implementation rejects names that are not valid npm package names, including traversal-shaped values, reserved names such as node_modules, and __proto__. This is important because the name becomes a directory under node_modules/.pnpm-config/<name> and a store path segment.
Second, config dependency versions are now validated with semver.valid() in assertValidConfigDepVersion.ts. The patch constrains versions to exact semver values before they are used as store path segments, preventing traversal payloads embedded in the version field.
Third, normalizeConfigDeps.ts now invokes these validators before any path is built from the supplied name or version. That ordering matters: validation occurs before path derivation, not after filesystem effects.
Fourth, the hoisted alias sink is strengthened in safeJoinModulesDir.ts. The function now validates aliases as npm package names before joining them under modulesDir, and it retains the containment check as a secondary guard. This directly addresses the lockfile-originated alias case described in the advisory. The updated tests add reserved aliases .bin, .pnpm, and node_modules, which are especially relevant because they may resolve inside node_modules and therefore evade a pure containment-only defense while still corrupting pnpm-owned layout.
if (!validateNpmPackageName(alias).validForOldPackages) {
throw invalidDependencyNameError(modulesDir, alias)
}
if (semver.valid(version) == null) {
throw new PnpmError(
'INVALID_CONFIG_DEP_VERSION',
`The config dependency "${name}" has an invalid version "${version}"`,
{ hint: 'A config dependency version must be an exact semver version.' }
)
}The test additions are also meaningful. They verify rejection of traversal names, traversal versions, and __proto__, and they assert that no PWNED entry appears in either the working directory or store after rejection. That gives evidence the fix is preventing filesystem side effects, not merely changing error messages.
Review
Pros
- The patch addresses the root trust boundary: untrusted dependency identifiers are validated before being used as path segments.
- Validation is applied at the filesystem sink for hoisted aliases, which is the correct place to defend against lockfile- and snapshot-derived data that may bypass earlier manifest validation.
- The defense is layered. Alias/name validation is combined with a containment check, reducing dependence on any single assumption about path normalization behavior.
- The reserved-name handling closes an important gap that a containment-only approach cannot catch. Aliases like
.binand.pnpmstay withinnode_modulesbut can still overwrite pnpm-managed directories. - Version validation with exact semver is appropriately strict for a field that becomes a store path segment.
- Regression tests are concrete and security-relevant, including negative assertions that no attacker-controlled artifact is created.
Cons
- The patch is tightly scoped to the identified sinks; it does not by itself prove that every other path-construction site in the broader install pipeline has equivalent validation.
- The use of
validForOldPackagesintentionally mirrors existing pnpm behavior, but it inherits the semantics of that library rather than defining a pnpm-specific allowlist. That is reasonable, though it means policy is partly delegated. - The provided source summary references pacquet in the vulnerability title, but the patch material here is pnpm-focused. Based on the supplied sources, this review can only assess the pnpm changes.
Verdict
Root-cause.
This patch fixes the underlying issue class in the affected pnpm paths by preventing attacker-controlled names, aliases, and versions from being interpreted as filesystem path components. It does not merely block one payload shape; it enforces semantic validity at the points where data crosses into directory creation and symlink placement, and it preserves containment checks as a secondary control. Given the advisory scope and the supplied diff, this is a technically sound and source-grounded remediation for the pnpm side of GHSA-FR4H-3CPH-29XV.
Recommended Labs
Try this vulnerability pattern yourself with hands-on labs.
- Path Traversal.js
Best direct match for this pnpm/pacquet advisory because the issue is a JavaScript/Node dependency-resolution path traversal problem. This hands-on lab maps closely to CWE-22/CWE-23 style unsafe path handling and helps practise defensive validation and containment of file paths.
- Path Traversal II.js
A stronger follow-up lab for the same vulnerability class in the JavaScript ecosystem. Recommended because the GHSA describes more than simple read access: malicious lockfiles can influence write targets and binary placement, so a medium-difficulty lab is useful for learning more robust normalization, boundary checks, and defense-in-depth fixes.
- Slipstream.js
Useful adjacent training for archive/dependency extraction risks similar to malicious package metadata or lockfile-controlled paths. Zip Slip overlaps with path traversal and arbitrary file overwrite themes, making this a good defensive lab to reinforce safe extraction, canonicalization, and destination allowlisting.