mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-29 18:35:18 -04:00
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).
This commit is contained in:
7
.changeset/reject-traversal-dependency-aliases.md
Normal file
7
.changeset/reject-traversal-dependency-aliases.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@pnpm/installing.deps-resolver": patch
|
||||
"@pnpm/fs.symlink-dependency": patch
|
||||
"pnpm": patch
|
||||
---
|
||||
|
||||
Reject dependency aliases that contain path-traversal segments (such as `@x/../../../../../.git/hooks`) when reading them from a package manifest or symlinking them into `node_modules`. A malicious registry package could otherwise use a transitive dependency key to make `pnpm install` create symlinks at attacker-chosen paths outside the intended `node_modules` directory.
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2660,6 +2660,7 @@ dependencies = [
|
||||
"pacquet-lockfile",
|
||||
"pacquet-package-manifest",
|
||||
"pacquet-patching",
|
||||
"pacquet-resolving-parse-wanted-dependency",
|
||||
"pacquet-resolving-resolver-base",
|
||||
"pipe-trait",
|
||||
"pretty_assertions",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { linkLogger } from '@pnpm/core-loggers'
|
||||
import { symlinkDir, symlinkDirSync } from 'symlink-dir'
|
||||
|
||||
import { safeJoinModulesDir } from './safeJoinModulesDir.js'
|
||||
|
||||
export { symlinkDirectRootDependency } from './symlinkDirectRootDependency.js'
|
||||
|
||||
export async function symlinkDependency (
|
||||
@@ -10,7 +10,7 @@ export async function symlinkDependency (
|
||||
destModulesDir: string,
|
||||
importAs: string
|
||||
): Promise<{ reused: boolean, warn?: string }> {
|
||||
const link = path.join(destModulesDir, importAs)
|
||||
const link = safeJoinModulesDir(destModulesDir, importAs)
|
||||
linkLogger.debug({ target: dependencyRealLocation, link })
|
||||
return symlinkDir(dependencyRealLocation, link)
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export function symlinkDependencySync (
|
||||
destModulesDir: string,
|
||||
importAs: string
|
||||
): { reused: boolean, warn?: string } {
|
||||
const link = path.join(destModulesDir, importAs)
|
||||
const link = safeJoinModulesDir(destModulesDir, importAs)
|
||||
linkLogger.debug({ target: dependencyRealLocation, link })
|
||||
return symlinkDirSync(dependencyRealLocation, link)
|
||||
}
|
||||
|
||||
19
fs/symlink-dependency/src/safeJoinModulesDir.ts
Normal file
19
fs/symlink-dependency/src/safeJoinModulesDir.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import path from 'node:path'
|
||||
|
||||
// `path.join(modulesDir, alias)` paired with a containment check, so a
|
||||
// caller can't accidentally use the joined path without verifying that
|
||||
// it lives inside `modulesDir`. Earlier passes reject path-traversal
|
||||
// aliases at manifest-read time, but this layer also runs for paths
|
||||
// reconstructed from lockfiles and snapshots, so the check stays here
|
||||
// as a final guarantee.
|
||||
export function safeJoinModulesDir (modulesDir: string, alias: string): string {
|
||||
const link = path.join(modulesDir, alias)
|
||||
const resolvedDir = path.resolve(modulesDir)
|
||||
const resolvedLink = path.resolve(link)
|
||||
if (resolvedLink === resolvedDir || !resolvedLink.startsWith(resolvedDir + path.sep)) {
|
||||
const error = new Error(`Refusing to symlink dependency outside ${modulesDir}: alias ${JSON.stringify(alias)} resolves to ${resolvedLink}`) as Error & { code: string }
|
||||
error.code = 'ERR_PNPM_INVALID_DEPENDENCY_NAME'
|
||||
throw error
|
||||
}
|
||||
return link
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import util from 'node:util'
|
||||
|
||||
import {
|
||||
@@ -9,6 +8,8 @@ import {
|
||||
import type { DependenciesField } from '@pnpm/types'
|
||||
import { symlinkDir } from 'symlink-dir'
|
||||
|
||||
import { safeJoinModulesDir } from './safeJoinModulesDir.js'
|
||||
|
||||
const DEP_TYPE_BY_DEPS_FIELD_NAME = {
|
||||
dependencies: 'prod',
|
||||
devDependencies: 'dev',
|
||||
@@ -45,7 +46,7 @@ export async function symlinkDirectRootDependency (
|
||||
}
|
||||
}
|
||||
|
||||
const dest = path.join(destModulesDirReal, importAs)
|
||||
const dest = safeJoinModulesDir(destModulesDirReal, importAs)
|
||||
const { reused } = await symlinkDir(dependencyLocation, dest)
|
||||
if (reused) return // if the link was already present, don't log
|
||||
rootLogger.debug({
|
||||
|
||||
36
fs/symlink-dependency/test/safeJoinModulesDir.test.ts
Normal file
36
fs/symlink-dependency/test/safeJoinModulesDir.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
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', '', '.']
|
||||
|
||||
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' }))
|
||||
})
|
||||
@@ -70,6 +70,7 @@
|
||||
"safe-promise-defer": "catalog:",
|
||||
"semver": "catalog:",
|
||||
"semver-range-intersect": "catalog:",
|
||||
"validate-npm-package-name": "catalog:",
|
||||
"version-selector-type": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -81,7 +82,8 @@
|
||||
"@pnpm/logger": "workspace:*",
|
||||
"@types/normalize-path": "catalog:",
|
||||
"@types/ramda": "catalog:",
|
||||
"@types/semver": "catalog:"
|
||||
"@types/semver": "catalog:",
|
||||
"@types/validate-npm-package-name": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.13"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Dependencies, DependenciesMeta, DependencyManifest } from '@pnpm/types'
|
||||
import { pickBy } from 'ramda'
|
||||
|
||||
import { assertValidDependencyAliases } from './validateDependencyAlias.js'
|
||||
|
||||
export interface WantedDependency {
|
||||
alias: string
|
||||
bareSpecifier: string // package reference
|
||||
@@ -10,9 +12,17 @@ export interface WantedDependency {
|
||||
saveCatalogName?: string
|
||||
}
|
||||
|
||||
type GetNonDevWantedDependenciesManifest = Pick<DependencyManifest, 'bundleDependencies' | 'bundledDependencies' | 'optionalDependencies' | 'dependencies' | 'dependenciesMeta'>
|
||||
type GetNonDevWantedDependenciesManifest = Pick<DependencyManifest, 'bundleDependencies' | 'bundledDependencies' | 'optionalDependencies' | 'dependencies' | 'dependenciesMeta'> & {
|
||||
name?: string
|
||||
version?: string
|
||||
}
|
||||
|
||||
export function getNonDevWantedDependencies (pkg: GetNonDevWantedDependenciesManifest): WantedDependency[] {
|
||||
const pkgDescription = pkg.name != null
|
||||
? `Package "${pkg.name}${pkg.version != null ? `@${pkg.version}` : ''}"`
|
||||
: 'Package'
|
||||
assertValidDependencyAliases(pkg.dependencies, pkgDescription)
|
||||
assertValidDependencyAliases(pkg.optionalDependencies, pkgDescription)
|
||||
let bd = pkg.bundledDependencies ?? pkg.bundleDependencies
|
||||
if (bd === true) {
|
||||
bd = pkg.dependencies != null ? Object.keys(pkg.dependencies) : []
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
ProjectManifest,
|
||||
} from '@pnpm/types'
|
||||
|
||||
import { assertValidDependencyAliases } from './validateDependencyAlias.js'
|
||||
|
||||
export interface WantedDependency {
|
||||
alias: string
|
||||
bareSpecifier: string // package reference
|
||||
@@ -23,6 +25,10 @@ export function getWantedDependencies (
|
||||
includeDirect?: IncludedDependencies
|
||||
}
|
||||
): WantedDependency[] {
|
||||
assertValidDependencyAliases(pkg.dependencies, 'The current package')
|
||||
assertValidDependencyAliases(pkg.devDependencies, 'The current package')
|
||||
assertValidDependencyAliases(pkg.optionalDependencies, 'The current package')
|
||||
assertValidDependencyAliases(pkg.peerDependencies, 'The current package')
|
||||
let depsToInstall = filterDependenciesByType(pkg,
|
||||
opts?.includeDirect ?? {
|
||||
dependencies: true,
|
||||
|
||||
31
installing/deps-resolver/src/validateDependencyAlias.ts
Normal file
31
installing/deps-resolver/src/validateDependencyAlias.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import validateNpmPackageName from 'validate-npm-package-name'
|
||||
|
||||
// An alias is the directory name pnpm creates inside `node_modules`, so
|
||||
// it must be a valid npm package name. Anything else (path-traversal
|
||||
// shapes such as `@x/../../../../../.git/hooks`, control characters,
|
||||
// names that collide with pnpm's own `node_modules` layout such as
|
||||
// `.bin` / `.pnpm` / `node_modules`) is rejected. Matches the
|
||||
// `validForOldPackages` check `parseWantedDependency` applies to
|
||||
// CLI-given names.
|
||||
export function isValidDependencyAlias (alias: string): boolean {
|
||||
return typeof alias === 'string' && validateNpmPackageName(alias).validForOldPackages
|
||||
}
|
||||
|
||||
export function assertValidDependencyAliases (
|
||||
deps: Record<string, unknown> | undefined,
|
||||
parentPkgDescription: string
|
||||
): void {
|
||||
if (deps == null) return
|
||||
for (const alias of Object.keys(deps)) {
|
||||
if (!isValidDependencyAlias(alias)) {
|
||||
throw new PnpmError(
|
||||
'INVALID_DEPENDENCY_NAME',
|
||||
`${parentPkgDescription} contains a dependency with an invalid name: ${JSON.stringify(alias)}`,
|
||||
{
|
||||
hint: 'A dependency name must be a valid npm package name — a single `name` or `@scope/name` consisting of URL-friendly characters, with no leading `.` or `_`, and not equal to reserved names such as `node_modules`.',
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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()
|
||||
})
|
||||
@@ -11,12 +11,13 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
pacquet-catalogs-resolver = { workspace = true }
|
||||
pacquet-catalogs-types = { workspace = true }
|
||||
pacquet-deps-path = { workspace = true }
|
||||
pacquet-package-manifest = { workspace = true }
|
||||
pacquet-patching = { workspace = true }
|
||||
pacquet-resolving-resolver-base = { workspace = true }
|
||||
pacquet-catalogs-resolver = { workspace = true }
|
||||
pacquet-catalogs-types = { workspace = true }
|
||||
pacquet-deps-path = { workspace = true }
|
||||
pacquet-package-manifest = { workspace = true }
|
||||
pacquet-patching = { workspace = true }
|
||||
pacquet-resolving-parse-wanted-dependency = { workspace = true }
|
||||
pacquet-resolving-resolver-base = { workspace = true }
|
||||
|
||||
async-recursion = { workspace = true }
|
||||
derive_more = { workspace = true }
|
||||
|
||||
@@ -56,6 +56,7 @@ mod resolve_dependency_tree;
|
||||
mod resolve_importer;
|
||||
mod resolve_peers;
|
||||
mod resolved_tree;
|
||||
mod validate_dependency_alias;
|
||||
|
||||
pub use dependencies_graph::{
|
||||
DependenciesGraph, DependenciesGraphNode, MissingPeer, PeerDependencyIssue,
|
||||
@@ -78,6 +79,7 @@ pub use resolved_tree::{
|
||||
ChildEdge, DependenciesTree, DependenciesTreeNode, DirectDep, PeerDep, ResolvedPackage,
|
||||
ResolvedTree, TreeChildren,
|
||||
};
|
||||
pub use validate_dependency_alias::is_valid_dependency_alias;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -102,6 +102,20 @@ pub enum ResolveDependencyTreeError {
|
||||
specifier: String,
|
||||
resolved_via: String,
|
||||
},
|
||||
|
||||
/// A dependency alias contained a path-separator segment that would
|
||||
/// escape the intended `node_modules` directory when joined onto a
|
||||
/// modules path. Mirrors pnpm's
|
||||
/// [`INVALID_DEPENDENCY_NAME`](https://github.com/pnpm/pnpm/blob/main/installing/deps-resolver/src/validateDependencyAlias.ts).
|
||||
#[display(
|
||||
"{parent} contains a dependency with an invalid name: {alias:?}. Dependency names must be a single package name or \"@scope/name\" — they cannot contain path-separator segments such as \"..\"."
|
||||
)]
|
||||
#[diagnostic(code(INVALID_DEPENDENCY_NAME))]
|
||||
InvalidDependencyName {
|
||||
#[error(not(source))]
|
||||
parent: String,
|
||||
alias: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<PatchKeyConflictError> for ResolveDependencyTreeError {
|
||||
@@ -143,13 +157,17 @@ where
|
||||
{
|
||||
let ctx = TreeCtx::new(opts.base_opts).with_patched_dependencies(opts.patched_dependencies);
|
||||
let optional_names = importer_optional_dependency_names(manifest);
|
||||
let wanted: Vec<(String, String, bool)> = manifest
|
||||
.dependencies(dependency_groups)
|
||||
.map(|(name, range)| {
|
||||
let optional = optional_names.contains(name);
|
||||
(name.to_string(), range.to_string(), optional)
|
||||
})
|
||||
.collect();
|
||||
let mut wanted: Vec<(String, String, bool)> = Vec::new();
|
||||
for (name, range) in manifest.dependencies(dependency_groups) {
|
||||
if !crate::is_valid_dependency_alias(name) {
|
||||
return Err(ResolveDependencyTreeError::InvalidDependencyName {
|
||||
parent: "The current package".to_string(),
|
||||
alias: name.to_string(),
|
||||
});
|
||||
}
|
||||
let optional = optional_names.contains(name);
|
||||
wanted.push((name.to_string(), range.to_string(), optional));
|
||||
}
|
||||
let direct = extend_tree(&ctx, resolver, wanted).await?;
|
||||
Ok(ctx.into_resolved_tree(direct))
|
||||
}
|
||||
@@ -580,7 +598,7 @@ where
|
||||
let child_specs = match child_specs {
|
||||
Some(specs) => specs,
|
||||
None => {
|
||||
let specs = Arc::new(extract_children(&result));
|
||||
let specs = Arc::new(extract_children(&result)?);
|
||||
lock_recoverable(&ctx.children_specs_by_id)
|
||||
.entry(id.clone())
|
||||
.or_insert_with(|| Arc::clone(&specs));
|
||||
@@ -770,21 +788,45 @@ fn render_specifier(wanted: &WantedDependency) -> String {
|
||||
/// `optionalDependencies`. The walker propagates this through
|
||||
/// `current_is_optional` so [`ResolvedPackage::optional`] reflects
|
||||
/// whether every path to the node went through an optional edge.
|
||||
fn extract_children(result: &pacquet_resolving_resolver_base::ResolveResult) -> Vec<ChildSpec> {
|
||||
let Some(manifest) = result.manifest.as_ref() else { return Vec::new() };
|
||||
fn extract_children(
|
||||
result: &pacquet_resolving_resolver_base::ResolveResult,
|
||||
) -> Result<Vec<ChildSpec>, ResolveDependencyTreeError> {
|
||||
let Some(manifest) = result.manifest.as_ref() else { return Ok(Vec::new()) };
|
||||
let parent = render_parent(result);
|
||||
let mut out = Vec::new();
|
||||
collect_deps(manifest, "dependencies", false, &mut out);
|
||||
collect_deps(manifest, "optionalDependencies", true, &mut out);
|
||||
out
|
||||
collect_deps(manifest, "dependencies", false, &parent, &mut out)?;
|
||||
collect_deps(manifest, "optionalDependencies", true, &parent, &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn collect_deps(manifest: &Value, key: &str, optional: bool, out: &mut Vec<ChildSpec>) {
|
||||
let Some(map) = manifest.get(key).and_then(Value::as_object) else { return };
|
||||
fn collect_deps(
|
||||
manifest: &Value,
|
||||
key: &str,
|
||||
optional: bool,
|
||||
parent: &str,
|
||||
out: &mut Vec<ChildSpec>,
|
||||
) -> Result<(), ResolveDependencyTreeError> {
|
||||
let Some(map) = manifest.get(key).and_then(Value::as_object) else { return Ok(()) };
|
||||
for (name, range) in map {
|
||||
if let Some(range_str) = range.as_str() {
|
||||
if !crate::is_valid_dependency_alias(name) {
|
||||
return Err(ResolveDependencyTreeError::InvalidDependencyName {
|
||||
parent: parent.to_string(),
|
||||
alias: name.clone(),
|
||||
});
|
||||
}
|
||||
out.push((name.clone(), range_str.to_string(), optional));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_parent(result: &pacquet_resolving_resolver_base::ResolveResult) -> String {
|
||||
if let Some(name_ver) = result.name_ver.as_ref() {
|
||||
format!("Package \"{}@{}\"", name_ver.name, name_ver.suffix)
|
||||
} else {
|
||||
format!("Package \"{}\"", result.id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract `peerDependencies` from a resolved package's manifest, with
|
||||
|
||||
@@ -155,13 +155,19 @@ where
|
||||
// direct dep with the right `wanted.optional` flag for the
|
||||
// `ResolvedPackage.optional` propagation.
|
||||
let optional_names = importer_optional_dependency_names(manifest);
|
||||
let initial_wanted: Vec<(String, String, bool)> = manifest
|
||||
.dependencies(groups)
|
||||
.map(|(name, range)| {
|
||||
let optional = optional_names.contains(name);
|
||||
(name.to_string(), range.to_string(), optional)
|
||||
})
|
||||
.collect();
|
||||
let mut initial_wanted: Vec<(String, String, bool)> = Vec::new();
|
||||
for (name, range) in manifest.dependencies(groups) {
|
||||
if !crate::is_valid_dependency_alias(name) {
|
||||
return Err(ResolveImporterError::Resolve(
|
||||
ResolveDependencyTreeError::InvalidDependencyName {
|
||||
parent: "The current package".to_string(),
|
||||
alias: name.to_string(),
|
||||
},
|
||||
));
|
||||
}
|
||||
let optional = optional_names.contains(name);
|
||||
initial_wanted.push((name.to_string(), range.to_string(), optional));
|
||||
}
|
||||
let initial_wanted = resolve_catalog_specifiers(initial_wanted, &catalogs)?;
|
||||
let mut direct = extend_tree(&ctx, resolver, initial_wanted).await?;
|
||||
update_preferred_versions_with_ctx(&ctx, &mut all_preferred_versions);
|
||||
|
||||
@@ -317,6 +317,52 @@ async fn declined_specifier_surfaces_spec_not_supported_error() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A transitive dependency whose alias contains `..` segments would
|
||||
/// escape the `node_modules` directory when joined onto a modules
|
||||
/// path. The walker rejects it before any further resolution work.
|
||||
/// Mirrors the pnpm-side fix in
|
||||
/// `installing/deps-resolver/src/validateDependencyAlias.ts`.
|
||||
#[tokio::test]
|
||||
async fn transitive_dep_with_traversal_alias_is_rejected() {
|
||||
let mut table = HashMap::new();
|
||||
table.insert(
|
||||
("normal".to_string(), "1.0.0".to_string()),
|
||||
fake_result(
|
||||
"normal",
|
||||
"1.0.0",
|
||||
serde_json::json!({
|
||||
"name": "normal",
|
||||
"version": "1.0.0",
|
||||
"dependencies": { "@x/../../../../../.git/hooks": "1.0.0" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
let resolver = StubResolver { table, calls: Mutex::new(Vec::new()) };
|
||||
let (_tmp, manifest) = fake_manifest(serde_json::json!({ "normal": "1.0.0" }));
|
||||
|
||||
let err = resolve_dependency_tree(
|
||||
&resolver,
|
||||
&manifest,
|
||||
[DependencyGroup::Prod],
|
||||
ResolveDependencyTreeOptions {
|
||||
base_opts: ResolveOptions::default(),
|
||||
patched_dependencies: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("traversal alias must error");
|
||||
match err {
|
||||
ResolveDependencyTreeError::InvalidDependencyName { parent, alias } => {
|
||||
assert_eq!(alias, "@x/../../../../../.git/hooks");
|
||||
assert!(
|
||||
parent.contains("normal"),
|
||||
"parent must name the offending package, got {parent:?}",
|
||||
);
|
||||
}
|
||||
other => panic!("expected InvalidDependencyName, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
mod block_exotic_subdeps {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Reject dependency aliases that aren't valid npm package names —
|
||||
//! anything else, joined with a `node_modules` path, can either escape
|
||||
//! the intended directory (`@x/../../../../../.git/hooks`) or collide
|
||||
//! with pnpm's own layout (`.bin`, `.pnpm`, `node_modules`). Mirrors
|
||||
//! pnpm's
|
||||
//! [`isValidDependencyAlias`](https://github.com/pnpm/pnpm/blob/main/installing/deps-resolver/src/validateDependencyAlias.ts),
|
||||
//! which routes through the same `validate-npm-package-name`
|
||||
//! `validForOldPackages` check that `parse_wanted_dependency` applies
|
||||
//! to CLI-given names.
|
||||
|
||||
use pacquet_resolving_parse_wanted_dependency::is_valid_old_npm_package_name;
|
||||
|
||||
/// `true` when `alias` is a valid npm package name that pnpm can safely
|
||||
/// use as a `node_modules/<alias>` directory.
|
||||
pub fn is_valid_dependency_alias(alias: &str) -> bool {
|
||||
is_valid_old_npm_package_name(alias)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_valid_dependency_alias;
|
||||
|
||||
#[test]
|
||||
fn accepts_valid_aliases() {
|
||||
for alias in
|
||||
["foo", "Foo", "@scope/name", "@s/x", "lodash.merge", "a_b", "a-b", "underscore"]
|
||||
{
|
||||
assert!(is_valid_dependency_alias(alias), "expected valid: {alias:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_aliases() {
|
||||
for alias in [
|
||||
"",
|
||||
"..",
|
||||
".",
|
||||
"/foo",
|
||||
"foo/bar",
|
||||
"@scope/name/extra",
|
||||
"@scope/../etc",
|
||||
"@x/../../../../../.git/hooks",
|
||||
r"foo\bar",
|
||||
r"C:\Windows\System32",
|
||||
"foo\0bar",
|
||||
"scope/name",
|
||||
"./foo",
|
||||
".bin",
|
||||
".pnpm",
|
||||
"_foo",
|
||||
"node_modules",
|
||||
"favicon.ico",
|
||||
" foo ",
|
||||
"foo bar",
|
||||
"foo?bar",
|
||||
] {
|
||||
assert!(!is_valid_dependency_alias(alias), "expected invalid: {alias:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -6063,6 +6063,9 @@ importers:
|
||||
semver-range-intersect:
|
||||
specifier: 'catalog:'
|
||||
version: 0.3.1
|
||||
validate-npm-package-name:
|
||||
specifier: 'catalog:'
|
||||
version: 7.0.2
|
||||
version-selector-type:
|
||||
specifier: 'catalog:'
|
||||
version: 3.0.0
|
||||
@@ -6085,6 +6088,9 @@ importers:
|
||||
'@types/semver':
|
||||
specifier: 'catalog:'
|
||||
version: 7.7.1
|
||||
'@types/validate-npm-package-name':
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.2
|
||||
|
||||
installing/deps-restorer:
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user