mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-28 09:55:39 -04:00
* 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).
68 lines
2.1 KiB
TypeScript
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()
|
|
})
|