Files
pnpm/fs/symlink-dependency/test/safeJoinModulesDir.test.ts
Zoltan Kochan f648e9b7c4 fix: contain hoisted dependency aliases (GHSA-fr4h-3cph-29xv) (#12343)
* fix: contain hoisted dependency aliases (GHSA-fr4h-3cph-29xv)

The `nodeLinker: hoisted` install restores its dependency graph straight
from the lockfile via `lockfileToHoistedDepGraph`, which joins each
dependency alias under a `node_modules` directory and imports the
package files there. On a frozen / up-to-date lockfile, resolution is
skipped entirely, so the alias validation added for the resolution path
never runs. A crafted lockfile alias such as `../../../escape` could
therefore escape the install root, and reserved aliases such as `.bin`,
`.pnpm`, or `node_modules` could overwrite pnpm-owned layout.

Validate every alias at the hoisted-graph directory sink. The shared
`safeJoinModulesDir` helper now rejects aliases that are not valid npm
package names (path-traversal, absolute, and reserved names) in addition
to its containment check, and the hoisted graph routes its `dep.name`
sink through it. Pacquet mirrors the boundary: `safe_join_modules_dir`
validates the hoister's `dep.0.name` before adding the graph node or
recursing, reusing the same dependency-name rule it already applies to
direct-dependency aliases. Both stacks surface
`ERR_PNPM_INVALID_DEPENDENCY_NAME`.

---
Written by an agent (Claude Code, claude-fable-5).

* fix: reject invalid dependency aliases at the lockfile verification gate

Add an always-on, policy-independent structural check to
verifyLockfileResolutions that rejects any importer or package-snapshot
dependency alias that is not a valid npm package name. A dependency
alias becomes a `node_modules/<alias>` directory at link time, so an
alias with path-traversal segments or a reserved name (`.bin`, `.pnpm`,
`node_modules`) could escape the install root or overwrite pnpm-owned
layout.

This complements the linker-sink guards: the verifier runs before any
fetch or filesystem work and covers every node linker at once, while the
sink guards still protect the `trustLockfile` path the verifier skips.
The check runs before the cache lookup so a record written by a version
that predates the rule cannot fast-path around it, and before the
`packages` guard so a tampered importer alias is caught even when nothing
is installed.

`isValidDependencyAlias` is now exported from `@pnpm/installing.deps-resolver`
and reused here. Pacquet mirrors the gate in its lockfile-verification
crate with a matching `ERR_PNPM_INVALID_DEPENDENCY_NAME` verdict.

---
Written by an agent (Claude Code, claude-fable-5).

* docs(package-manager): drop redundant explicit intra-doc link target

`is_valid_dependency_alias` is in scope via `use`, so the bare
intra-doc link resolves on its own. The explicit path target tripped
`rustdoc::redundant-explicit-links` under the CI Doc job's
`cargo doc --document-private-items` (the local pre-push hook runs
`cargo doc` without that flag, so it didn't surface).

---
Written by an agent (Claude Code, claude-fable-5).

* refactor(lockfile-verification): fold the alias check into the single candidate pass

The dependency-alias check ran as its own full traversal of the lockfile
in addition to collectCandidates' existing pass over every package
snapshot. Fold it into that pass instead: collectCandidates now also
validates each importer and snapshot dependency alias and returns the
invalid ones alongside the resolution-shape violations, so the lockfile
is walked once per verification rather than twice.

Because collectCandidates runs after the verification-cache lookup, the
alias check is now covered by the cache the same way the resolution-shape
check is: a new dependencyAliasCheck cache identity makes a record
written before this rule existed fail canTrustPastCheck, forcing a
re-verification. The shared helper is renamed
withOfflineCheckCacheIdentities and appends both offline-structural-check
identities.

No behavior change for valid lockfiles; the same
ERR_PNPM_INVALID_DEPENDENCY_NAME is thrown for invalid aliases. Mirrored
in pacquet's lockfile-verification crate.

---
Written by an agent (Claude Code, claude-fable-5).

* refactor: declare pushInvalidAliases after its caller, trim duplicated comments

Move `pushInvalidAliases` below `collectCandidates`, following the
repo's declare-after-use convention. Collapse the repeated "an alias
becomes a node_modules directory, so a traversal/reserved name escapes
or overwrites layout" explanation that was copied across the verifier,
the hoisted-graph error, and the pacquet mirror down to a single
reference each — the full rationale lives once in the validating sink
(`safeJoinModulesDir` / `safe_join_modules_dir`) and the user-facing
error hints.

---
Written by an agent (Claude Code, claude-fable-5).
2026-06-12 09:46:57 +02:00

60 lines
2.2 KiB
TypeScript

import fs from 'node:fs'
import path from 'node:path'
import { expect, test } from '@jest/globals'
import { symlinkDependency, symlinkDependencySync, symlinkDirectRootDependency } from '@pnpm/fs.symlink-dependency'
import { tempDir } from '@pnpm/prepare'
const escapeAliases = [
'@x/../../../etc',
'../sibling',
'',
'.',
// Reserved names that resolve *inside* `node_modules` but would
// overwrite pnpm-owned layout, so the containment check alone can't
// catch them.
'.bin',
'.pnpm',
'node_modules',
]
test.each(escapeAliases)('symlinkDependency refuses alias %p', async (alias) => {
const tmp = tempDir(false)
const destModulesDir = path.join(tmp, 'node_modules')
fs.mkdirSync(destModulesDir)
await expect(
symlinkDependency(path.join(tmp, 'dep'), destModulesDir, alias)
).rejects.toThrow(expect.objectContaining({ code: 'ERR_PNPM_INVALID_DEPENDENCY_NAME' }))
})
test.each(escapeAliases)('symlinkDependencySync refuses alias %p', (alias) => {
const tmp = tempDir(false)
const destModulesDir = path.join(tmp, 'node_modules')
fs.mkdirSync(destModulesDir)
expect(() => {
symlinkDependencySync(path.join(tmp, 'dep'), destModulesDir, alias)
}).toThrow(expect.objectContaining({ code: 'ERR_PNPM_INVALID_DEPENDENCY_NAME' }))
})
test.each(escapeAliases)('symlinkDirectRootDependency refuses alias %p', async (alias) => {
const tmp = tempDir(false)
const destModulesDir = path.join(tmp, 'node_modules')
fs.mkdirSync(destModulesDir)
await expect(symlinkDirectRootDependency(path.join(tmp, 'dep'), destModulesDir, alias, {
linkedPackage: { name: 'dep', version: '1.0.0' },
prefix: '',
})).rejects.toThrow(expect.objectContaining({ code: 'ERR_PNPM_INVALID_DEPENDENCY_NAME' }))
})
const validAliases = ['foo', '@scope/name', 'foo.bar']
test.each(validAliases)('symlinkDependency accepts valid alias %p', async (alias) => {
const tmp = tempDir(false)
const destModulesDir = path.join(tmp, 'node_modules')
fs.mkdirSync(destModulesDir)
const dep = path.join(tmp, 'dep')
fs.mkdirSync(dep)
await expect(symlinkDependency(dep, destModulesDir, alias)).resolves.toBeDefined()
expect(fs.existsSync(path.join(destModulesDir, alias))).toBe(true)
})