Files
pnpm/installing/deps-resolver/test/validateDependencyAlias.test.ts
Zoltan Kochan ad84fffd46 fix: reject path-traversal segments in dependency aliases (#11954)
* fix: reject path-traversal segments in dependency aliases

A transitive registry package can use a dependency-alias key like
`@x/../../../../../.git/hooks` to make `pnpm install` create a symlink
outside the intended `node_modules` directory, since pnpm passes the
alias straight into `path.join(modulesDir, alias)` without checking
that the joined path stays inside `modulesDir`.

Reject aliases that aren't a single `name` or `@scope/name` shape at
manifest-read time (both the importer's manifest and every transitive
package manifest) and re-check at the symlink layer as defense in
depth. Mirror the fix in pacquet's deps-resolver.

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

* fix(pacquet): use raw strings in alias validator tests for dylint

Perfectionist's `prefer-raw-string` lint rejects the two
backslash-escaped test inputs.

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

* refactor: tighten dependency-alias validator to validate-npm-package-name

An alias is the directory name pnpm creates inside `node_modules`, so
the only valid shapes are a single `name` or `@scope/name` consisting
of URL-friendly characters with no leading `.` / `_`, and not equal to
reserved names such as `node_modules`. That's the same
`validForOldPackages` rule `parseWantedDependency` already applies to
CLI-given names — the manifest-read path should match. Route both
stacks through it so `.bin`, `.pnpm`, `node_modules`, `favicon.ico`,
whitespace, and non-URL-friendly characters are all rejected alongside
the path-traversal shapes the narrow validator caught.

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

* refactor: collapse symlink-layer assertion + path.join into safeJoinModulesDir

The two-step pattern of "assert the alias stays in the dir" then "join
the dir and the alias" left it possible for a caller to use the join
without the assertion. Fold them into a single `safeJoinModulesDir`
that returns the joined path and throws on escape, so the check is
unmissable.

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

* test(symlink-dependency): cover the path-equals-dir guard branch

The earlier tests only exercised the `!startsWith` branch with
`'../sibling'` and `'@x/../../../etc'`. Add `''` and `'.'` as alias
cases — both resolve to the modules dir itself and hit the
`resolvedLink === resolvedDir` branch of `safeJoinModulesDir`.

---
Written by an agent (Claude Code, claude-opus-4-7).
2026-05-26 17:25:25 +02:00

68 lines
2.1 KiB
TypeScript

import { expect, test } from '@jest/globals'
import { assertValidDependencyAliases, isValidDependencyAlias } from '../lib/validateDependencyAlias.js'
test.each([
['foo'],
['Foo'],
['@scope/name'],
['@s/x'],
['lodash.merge'],
['a_b'],
['a-b'],
['x'],
['underscore'],
])('accepts %p', (alias) => {
expect(isValidDependencyAlias(alias)).toBe(true)
})
test.each([
['', 'empty string'],
['..', 'parent traversal'],
['.', 'current dir'],
['/foo', 'absolute posix'],
['foo/bar', 'unscoped slash'],
['@scope/name/extra', 'scoped with extra segment'],
['@scope/../etc', 'scope with parent traversal'],
['@x/../../../../../.git/hooks', 'PoC payload'],
['foo\\bar', 'backslash'],
['C:\\Windows\\System32', 'windows absolute'],
['foo\0bar', 'null byte'],
['scope/name', 'two segments without @'],
['./foo', 'current dir prefix'],
['.bin', 'leading dot (collides with pnpm .bin)'],
['.pnpm', 'leading dot (collides with pnpm .pnpm)'],
['_foo', 'leading underscore'],
['node_modules', 'reserved name'],
['favicon.ico', 'reserved name'],
[' foo ', 'leading/trailing whitespace'],
['foo bar', 'embedded whitespace'],
['foo?bar', 'non-url-friendly character'],
])('rejects %s (%s)', (alias) => {
expect(isValidDependencyAlias(alias)).toBe(false)
})
test('assertValidDependencyAliases throws ERR_PNPM_INVALID_DEPENDENCY_NAME for malicious aliases', () => {
expect(() => {
assertValidDependencyAliases({ '@x/../../../../../.git/hooks': '1.0.0' }, 'Package "bad@1.0.0"')
}).toThrow(expect.objectContaining({
code: 'ERR_PNPM_INVALID_DEPENDENCY_NAME',
message: expect.stringContaining('Package "bad@1.0.0" contains a dependency with an invalid name'),
}))
})
test('assertValidDependencyAliases is a no-op for undefined and empty input', () => {
expect(() => {
assertValidDependencyAliases(undefined, 'pkg')
}).not.toThrow()
expect(() => {
assertValidDependencyAliases({}, 'pkg')
}).not.toThrow()
})
test('assertValidDependencyAliases is a no-op for valid aliases', () => {
expect(() => {
assertValidDependencyAliases({ foo: '1.0.0', '@scope/bar': '2.0.0' }, 'pkg')
}).not.toThrow()
})