fix(security): contain lockfile & manifest package names/slots against path traversal (pnpm + pacquet) (#12872)

Contain crafted-lockfile and malicious-manifest path traversal across both
stacks. Three advisories:

- GHSA-c59q-g84q-2gj5 (TS): lockfile depPath name -> isolated linker + PnP.
- GHSA-vq4v-j7r6-jq4m (TS + pacquet): dependency manifest `name` from the
  fetched tarball (e.g. @x/../../../path) -> resolver/linker.
- GHSA-2rx9-3g3h-c2jv (pacquet): lockfile alias / snapshot key name, and the
  global-virtual-store version segment; also bypassable under trustLockfile.

TS: apply safeJoinModulesDir at lockfileToDepGraph and the PnP
packageLocation; validate manifest names in deps-resolver / resolvePeers /
env-installer; contain the global-virtual-store slot dir against its store
root in iteratePkgsForVirtualStore so a traversal version segment is rejected.

pacquet: add verify_lockfile_dependency_names (offline check over importer
aliases, snapshot package names, and snapshot dependency aliases) run
unconditionally in InstallFrozenLockfile::run so trustLockfile cannot bypass
it; add validate_virtual_store_slot_containment for the GVS version escape;
guard the sink joins (create_symlink_layout, create_virtual_dir_by_snapshot,
hoist, install_package_from_registry) and the resolver with
safe_join_modules_dir.

All rejections surface ERR_PNPM_INVALID_DEPENDENCY_NAME.
This commit is contained in:
Zoltan Kochan
2026-07-09 15:46:15 +02:00
committed by GitHub
parent 8ecbbd356c
commit 51300fd41c
44 changed files with 942 additions and 87 deletions

View File

@@ -0,0 +1,8 @@
---
"@pnpm/deps.graph-builder": patch
"@pnpm/deps.graph-hasher": patch
"@pnpm/lockfile.to-pnp": patch
"pnpm": patch
---
Prevent a crafted `pnpm-lock.yaml` from writing package content outside the virtual store. A dependency path key whose name reconstructs to a path-traversal sequence (e.g. `../../../tmp/x@1.0.0`) is now rejected by the isolated (virtual-store) linker and the Plug'n'Play resolver map, matching the containment already applied to the hoisted linker. Under the global virtual store, a traversal in the version-derived path segment (e.g. a snapshot `version: "../../x"`) is now rejected at `formatGlobalVirtualStorePath`, the single point every global-virtual-store slot path funnels through — closing the same escape in the isolated linker, the resolver's dependency-graph builder, and the config-dependency installer.

View File

@@ -0,0 +1,8 @@
---
"@pnpm/installing.deps-resolver": patch
"@pnpm/deps.graph-builder": patch
"@pnpm/installing.env-installer": patch
"pnpm": patch
---
Fixed a path traversal vulnerability where a dependency whose manifest `name` was a scoped path traversal (e.g. `@x/../../../<path>`) could be written outside `node_modules` to an attacker-controlled location during `pnpm install`, even with `--ignore-scripts`. The isolated linker now validates the package name before using it as a directory name, matching the existing protection in the hoisted linker.

View File

@@ -52,6 +52,52 @@ fn should_install_dependencies() {
drop((root, mock_instance));
}
/// A project manifest that declares a dependency under a path-traversal
/// name is rejected by the resolver on a fresh install — before any
/// resolution or fetch, and long before the name could become a
/// `node_modules/<alias>` directory. This is the fresh-resolve
/// counterpart to the frozen-lockfile name check. Surfaces
/// `ERR_PNPM_INVALID_DEPENDENCY_NAME`.
#[test]
fn install_rejects_a_traversal_dependency_name_in_the_manifest() {
let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } =
CommandTempCwd::init().add_mocked_registry();
let AddMockedRegistry { mock_instance, .. } = npmrc_info;
let manifest_path = workspace.join("package.json");
let package_json_content = serde_json::json!({
"dependencies": {
"../../escaped-link": "1.0.0",
},
});
fs::write(&manifest_path, package_json_content.to_string()).expect("write to package.json");
let output = pacquet.with_arg("install").output().expect("spawn pacquet install");
assert!(
!output.status.success(),
"the resolver must reject a traversal dependency name (stderr: {})",
String::from_utf8_lossy(&output.stderr),
);
// The fresh-resolve path forwards the resolver's diagnostic
// transparently, so the rendered envelope carries the canonical
// `ERR_PNPM_INVALID_DEPENDENCY_NAME` code — matching the frozen path
// (see `lockfile_verification.rs`). The offending name is in the
// message too, but miette may wrap it across lines at narrow widths,
// so assert on the stable code and the unwrapped "invalid name"
// phrase instead.
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
assert!(
stderr.contains("ERR_PNPM_INVALID_DEPENDENCY_NAME") && stderr.contains("invalid name"),
"stderr must report the invalid-dependency-name code; got:\n{stderr}",
);
assert!(
!workspace.parent().is_some_and(|parent| parent.join("escaped-link").exists()),
"no link may be created outside the project",
);
drop((root, mock_instance));
}
#[test]
fn should_install_exec_files() {
let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } =

View File

@@ -226,3 +226,83 @@ fn trust_lockfile_cli_flag_skips_verification() {
drop((root, mock_instance));
}
/// Regression test for the crafted-lockfile path-traversal advisory
/// (GHSA-2rx9-3g3h-c2jv). A dependency name/alias that isn't a valid npm
/// package name — here a `../../escaped-link` snapshot key — would be
/// joined into a filesystem path at install time and escape the project.
/// The dependency-name check must run **even under `--trust-lockfile`**,
/// which disables the resolution-policy verification fan-out: the two
/// tests above prove that flag skips the *policy* gate, so this test pins
/// the complementary contract that the *structural* name check is not
/// skipped with it. The install must fail with
/// `ERR_PNPM_INVALID_DEPENDENCY_NAME` before any virtual-store slot is
/// materialized.
#[test]
fn trust_lockfile_still_rejects_traversal_dependency_name() {
let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } =
CommandTempCwd::init().add_mocked_registry();
let AddMockedRegistry { mock_instance, .. } = npmrc_info;
// A legit direct dependency keeps the frozen-lockfile freshness
// check happy; the crafted entry is an extra `snapshots:` /
// `packages:` key whose name is a path traversal. The verifier
// walks every snapshot, so an unreferenced hostile key is still
// rejected.
let manifest_path = workspace.join("package.json");
let package_json = serde_json::json!({
"dependencies": {
"@pnpm.e2e/hello-world-js-bin": "1.0.0",
},
});
fs::write(&manifest_path, package_json.to_string()).expect("write package.json");
let lockfile = "lockfileVersion: '9.0'\n\
importers:\n \
.:\n \
dependencies:\n \
'@pnpm.e2e/hello-world-js-bin':\n \
specifier: 1.0.0\n \
version: 1.0.0\n\
packages:\n \
'@pnpm.e2e/hello-world-js-bin@1.0.0':\n \
resolution: {integrity: sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==}\n \
'../../escaped-link@1.0.0':\n \
resolution: {integrity: sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==}\n\
snapshots:\n \
'@pnpm.e2e/hello-world-js-bin@1.0.0': {}\n \
'../../escaped-link@1.0.0': {}\n";
fs::write(workspace.join("pnpm-lock.yaml"), lockfile).expect("write lockfile");
let output = pacquet
.with_args(["install", "--frozen-lockfile", "--trust-lockfile"])
.output()
.expect("spawn pacquet install");
assert!(
!output.status.success(),
"the name check must reject the install even under --trust-lockfile (stderr: {})",
String::from_utf8_lossy(&output.stderr),
);
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
assert!(
stderr.contains("ERR_PNPM_INVALID_DEPENDENCY_NAME"),
"stderr must name the invalid-dependency-name code; got:\n{stderr}",
);
// The rejection happens before materialization, so nothing is
// extracted — neither the legit slot nor any escaped directory.
assert!(
!workspace.join("node_modules/.pnpm").exists(),
"the check must fail before any virtual-store materialization",
);
// Belt-and-suspenders: the traversal target of the crafted alias
// (`<workspace>/node_modules/../../escaped-link`) must not exist.
if let Some(parent) = workspace.parent() {
assert!(
!parent.join("escaped-link").exists(),
"no link may be created outside the project",
);
}
drop((root, mock_instance));
}

View File

@@ -11,6 +11,7 @@
//! JSONL stat-and-skip cache.
//!
//! Public surface today: [`verify_lockfile_resolutions()`],
//! [`verify_lockfile_dependency_names()`],
//! [`collect_resolution_policy_violations()`], [`hash_lockfile()`],
//! [`VerifyError`], and [`RenderedViolation`] — the last lets a caller
//! that resolved violations out-of-process (e.g. the pnpr client
@@ -35,5 +36,6 @@ pub use hash_lockfile::hash_lockfile;
pub use record_lockfile_verified::record_lockfile_verified;
pub use verify_lockfile_resolutions::{
RESOLUTION_SHAPE_MISMATCH_VIOLATION_CODE, VerifyLockfileResolutionsOptions,
collect_resolution_policy_violations, verify_lockfile_resolutions,
collect_resolution_policy_violations, verify_lockfile_dependency_names,
verify_lockfile_resolutions,
};

View File

@@ -67,6 +67,12 @@ pub async fn verify_lockfile_resolutions<Reporter: self::Reporter>(
verifiers: &[Arc<dyn ResolutionVerifier>],
opts: &VerifyLockfileResolutionsOptions<'_>,
) -> Result<(), VerifyError> {
// Offline structural gate first: reject invalid dependency names
// before the `packages`-absent short-circuit and the cache lookup,
// so a lockfile that carries a path-traversal alias but no
// `packages:` section (e.g. only `link:` deps) is still rejected.
verify_lockfile_dependency_names(lockfile)?;
if lockfile.packages.is_none() {
return Ok(());
}
@@ -123,10 +129,7 @@ pub async fn verify_lockfile_resolutions<Reporter: self::Reporter>(
cache_precomputed = result.precomputed;
}
let (candidates, shape_violations, invalid_aliases) = collect_candidates(lockfile);
if !invalid_aliases.is_empty() {
return Err(VerifyError::invalid_dependency_aliases(&invalid_aliases));
}
let (candidates, shape_violations) = collect_candidates(lockfile);
if !shape_violations.is_empty() {
return Err(build_verification_error(shape_violations));
}
@@ -205,7 +208,7 @@ pub async fn collect_resolution_policy_violations(
// Shape violations and invalid aliases are deliberately not
// collected here: they are hard tampering failures, not policy
// picks a caller may auto-exclude.
let (candidates, _shape_violations, _invalid_aliases) = collect_candidates(lockfile);
let (candidates, _shape_violations) = collect_candidates(lockfile);
// `Err(message)` is a transport failure the caller must surface rather than
// treat as "no violations" — see [`run_fan_out`].
run_fan_out(candidates, verifiers, concurrency).await
@@ -326,6 +329,61 @@ fn push_invalid_aliases<'alias>(
}
}
/// Collect every lockfile-controlled dependency name that isn't a valid
/// npm package name. Three sources, each of which becomes a
/// `node_modules/<name>` path component at install time:
///
/// - every importer's direct-dependency aliases,
/// - every snapshot's own package name (the `snapshots:` map key), and
/// - every snapshot's `dependencies` / `optionalDependencies` aliases.
///
/// A name carrying a `..` traversal, a `/`, or a reserved value such as
/// `node_modules` / `.bin` / `.pnpm` could make an install write outside
/// the intended directory or overwrite pnpm-owned layout.
fn collect_invalid_dependency_names(lockfile: &Lockfile) -> std::collections::BTreeSet<String> {
let mut invalid = std::collections::BTreeSet::new();
for importer in lockfile.importers.values() {
for deps in
[&importer.dependencies, &importer.dev_dependencies, &importer.optional_dependencies]
{
push_invalid_aliases(deps.iter().flatten().map(|(alias, _)| alias), &mut invalid);
}
}
if let Some(snapshots) = lockfile.snapshots.as_ref() {
for (key, snapshot) in snapshots {
push_invalid_aliases(std::iter::once(&key.name), &mut invalid);
for deps in [&snapshot.dependencies, &snapshot.optional_dependencies] {
push_invalid_aliases(deps.iter().flatten().map(|(alias, _)| alias), &mut invalid);
}
}
}
invalid
}
/// Reject a lockfile whose dependency names or aliases are not valid npm
/// package names.
///
/// This is an offline, network-free structural check — it does **not**
/// depend on the resolution-policy verifiers, so it must run on every
/// install, **including under `trustLockfile`**, which disables the
/// policy fan-out. A crafted lockfile alias becomes a filesystem path
/// (`node_modules/<alias>`, a virtual-store slot's inner
/// `node_modules/<name>`, a bin, a hoisted link), and pacquet's
/// [`PkgName`] parser does not validate against npm's package-name
/// rules, so an unchecked `..`/`/`-bearing name escapes the intended
/// directory.
///
/// Surfaces [`VerifyError::InvalidDependencyAlias`]
/// (`ERR_PNPM_INVALID_DEPENDENCY_NAME`) listing every offender.
pub fn verify_lockfile_dependency_names(lockfile: &Lockfile) -> Result<(), VerifyError> {
let invalid = collect_invalid_dependency_names(lockfile);
if invalid.is_empty() {
return Ok(());
}
let invalid: Vec<String> = invalid.into_iter().collect();
Err(VerifyError::invalid_dependency_aliases(&invalid))
}
/// One `(name, version, resolution)` tuple deduplicated from
/// `lockfile.packages`.
struct Candidate {
@@ -343,36 +401,10 @@ struct Candidate {
/// `BTreeMap` over a serialized key gives deterministic iteration
/// order for tests; the fan-out runs across the value iter so order
/// doesn't affect correctness, only the reproducibility of failures.
fn collect_candidates(
lockfile: &Lockfile,
) -> (Vec<Candidate>, Vec<ResolutionPolicyViolation>, Vec<String>) {
fn collect_candidates(lockfile: &Lockfile) -> (Vec<Candidate>, Vec<ResolutionPolicyViolation>) {
let Some(packages) = lockfile.packages.as_ref() else {
return (Vec::new(), Vec::new(), Vec::new());
return (Vec::new(), Vec::new());
};
// Pacquet keeps the alias-bearing maps in `importers` / `snapshots`,
// separate from the `packages` metadata the loop below walks, so
// they're scanned here in the same pass.
let mut invalid_aliases: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for importer in lockfile.importers.values() {
for deps in
[&importer.dependencies, &importer.dev_dependencies, &importer.optional_dependencies]
{
push_invalid_aliases(
deps.iter().flatten().map(|(alias, _)| alias),
&mut invalid_aliases,
);
}
}
if let Some(snapshots) = lockfile.snapshots.as_ref() {
for snapshot in snapshots.values() {
for deps in [&snapshot.dependencies, &snapshot.optional_dependencies] {
push_invalid_aliases(
deps.iter().flatten().map(|(alias, _)| alias),
&mut invalid_aliases,
);
}
}
}
let mut deduped: BTreeMap<String, Candidate> = BTreeMap::new();
let mut shape_violations = Vec::new();
for (key, metadata) in packages {
@@ -412,7 +444,7 @@ fn collect_candidates(
resolution: metadata.resolution.clone(),
});
}
(deduped.into_values().collect(), shape_violations, invalid_aliases.into_iter().collect())
(deduped.into_values().collect(), shape_violations)
}
/// Run every active verifier against every candidate with a

View File

@@ -883,6 +883,66 @@ snapshots:
assert!(matches!(err, VerifyError::InvalidDependencyAlias { .. }), "got {err:?}");
}
#[tokio::test]
async fn rejects_a_traversal_snapshot_package_name() {
// The snapshot's own package name (the `snapshots:` key) becomes
// `<slot>/node_modules/<name>` at extract time — a sink the alias
// scan of the dependency maps alone doesn't cover.
let lockfile = parse(
"lockfileVersion: '9.0'
packages:
../../../escape@1.0.0:
resolution: {integrity: sha512-deadbeef}
snapshots:
../../../escape@1.0.0: {}
",
);
let err = verify_lockfile_resolutions::<SilentReporter>(
&lockfile,
&[],
&VerifyLockfileResolutionsOptions::default(),
)
.await
.expect_err("a traversal snapshot package name must be rejected");
let VerifyError::InvalidDependencyAlias { breakdown, .. } = err else {
panic!("expected InvalidDependencyAlias, got {err:?}");
};
assert!(breakdown.contains("../../../escape"), "breakdown {breakdown:?}");
}
#[test]
fn verify_lockfile_dependency_names_rejects_a_packages_less_lockfile() {
// A lockfile with only `link:` deps has no `packages:` section, so
// the resolution fan-out short-circuits — but the offline name check
// must still reject a traversal importer alias.
let lockfile = parse(
"lockfileVersion: '9.0'
importers:
.:
dependencies:
'../../escaped-link':
specifier: link:./local
version: link:local
",
);
assert!(lockfile.packages.is_none(), "fixture must have no packages section");
let err = super::verify_lockfile_dependency_names(&lockfile)
.expect_err("a traversal alias in a packages-less lockfile must be rejected");
assert!(matches!(err, VerifyError::InvalidDependencyAlias { .. }), "got {err:?}");
}
#[test]
fn verify_lockfile_dependency_names_accepts_a_clean_lockfile() {
super::verify_lockfile_dependency_names(&parse(TWO_PKG_LOCKFILE))
.expect("a lockfile with valid names must pass");
}
#[tokio::test]
async fn accepts_valid_scoped_and_unscoped_aliases() {
let lockfile = parse(

View File

@@ -1,4 +1,7 @@
use crate::{SkippedSnapshots, SymlinkPackageError, VirtualStoreLayout, symlink_package};
use crate::{
SkippedSnapshots, SymlinkPackageError, VirtualStoreLayout,
safe_join_modules_dir::safe_join_modules_dir, symlink_package,
};
use pacquet_lockfile::{PkgName, SnapshotDepRef};
use std::{collections::HashMap, path::Path};
@@ -48,13 +51,19 @@ pub fn create_symlink_layout(
if skipped.contains(&target) {
return Ok(());
}
let target_name_str = target.name.to_string();
let alias_name_str = alias_name.to_string();
symlink_package(
&layout.slot_dir(&target).join("node_modules").join(&target_name_str),
&virtual_node_modules_dir.join(&alias_name_str),
// Both names are lockfile-derived and untrusted: `target.name`
// is the resolved package's own name and `alias_name` is the
// dependency key. A traversal-shaped name (`@x/../../...`) would
// otherwise let the symlink target or the symlink itself escape
// the slot's `node_modules`, so guard each join.
let symlink_target = safe_join_modules_dir(
&layout.slot_dir(&target).join("node_modules"),
&target.name.to_string(),
)
.map(drop)
.map_err(SymlinkPackageError::InvalidAlias)?;
let symlink_path = safe_join_modules_dir(virtual_node_modules_dir, &alias_name.to_string())
.map_err(SymlinkPackageError::InvalidAlias)?;
symlink_package(&symlink_target, &symlink_path).map(drop)
})
}

View File

@@ -1,4 +1,4 @@
use crate::{SkippedSnapshots, VirtualStoreLayout, create_symlink_layout};
use crate::{SkippedSnapshots, SymlinkPackageError, VirtualStoreLayout, create_symlink_layout};
use pacquet_lockfile::{PackageKey, PkgName, SnapshotDepRef};
use pretty_assertions::assert_eq;
use std::{collections::HashMap, fs, path::PathBuf};
@@ -236,3 +236,42 @@ fn alias_dep_links_under_alias_but_resolves_via_target() {
.expect("compute relative target");
assert_eq!(read, expected);
}
/// A dependency alias that is a scoped path traversal
/// (`@x/../../.../OUTSIDE`) must be rejected before any symlink is
/// created, rather than escaping the slot's `node_modules`.
/// `PkgName::parse` accepts such a name (its `bare` field keeps the
/// `../` segments), so the guard has to live at the join.
#[test]
fn rejects_traversal_dependency_alias() {
let tmp = tempdir().expect("tempdir");
let virtual_store_dir = tmp.path().to_path_buf();
let layout = VirtualStoreLayout::legacy(
virtual_store_dir,
pacquet_config::default_virtual_store_dir_max_length() as usize,
);
let traversal = format!("@x/{}OUTSIDE", "../".repeat(20));
let mut deps: HashMap<PkgName, SnapshotDepRef> = HashMap::new();
deps.insert(pkg_name(&traversal), dep_ref("1.0.0"));
let skipped = SkippedSnapshots::default();
let virtual_node_modules_dir = tmp.path().join("self/node_modules");
fs::create_dir_all(&virtual_node_modules_dir).unwrap();
let error = create_symlink_layout(
Some(&deps),
None,
&pkg_name("self"),
&skipped,
&layout,
&virtual_node_modules_dir,
)
.expect_err("traversal alias must be rejected");
assert!(matches!(error, SymlinkPackageError::InvalidAlias(_)), "got {error:?}");
// The guard fires before any symlink is created, so nothing was
// linked into (or out of) the slot's node_modules.
let linked = fs::read_dir(&virtual_node_modules_dir).unwrap().count();
assert_eq!(linked, 0);
}

View File

@@ -1,6 +1,7 @@
use crate::{
ImportIndexedDirError, ImportIndexedDirOpts, SkippedSnapshots, SymlinkPackageError,
VirtualStoreLayout, create_symlink_layout, import_indexed_dir,
safe_join_modules_dir::{InvalidDependencyAliasError, safe_join_modules_dir},
};
use derive_more::{Display, Error};
use miette::Diagnostic;
@@ -86,6 +87,12 @@ pub enum CreateVirtualDirError {
#[diagnostic(transparent)]
SymlinkPackage(#[error(source)] SymlinkPackageError),
/// The snapshot's package name is not a valid npm package name, so
/// joining it under the slot's `node_modules` could escape the
/// directory. Surfaces pnpm's `ERR_PNPM_INVALID_DEPENDENCY_NAME`.
#[diagnostic(transparent)]
InvalidAlias(#[error(source)] InvalidDependencyAliasError),
#[display("Failed to remove obsolete child link at {path:?}: {error}")]
#[diagnostic(code(pacquet_package_manager::remove_obsolete_child))]
RemoveObsoleteChild {
@@ -125,7 +132,9 @@ impl CreateVirtualDirBySnapshot<'_> {
}
})?;
let save_path = virtual_node_modules_dir.join(package_key.name.to_string());
let save_path =
safe_join_modules_dir(&virtual_node_modules_dir, &package_key.name.to_string())
.map_err(CreateVirtualDirError::InvalidAlias)?;
// `rayon::join` runs both closures in parallel on rayon's pool,
// returning only once both finish. `import_indexed_dir` is itself

View File

@@ -177,6 +177,45 @@ async fn run_emits_imported_event_after_import_indexed_dir() {
);
}
/// A snapshot key whose package name is a path traversal would become
/// the `<slot>/node_modules/<name>` extraction directory, escaping the
/// store. The guard rejects it before any package content is imported.
#[test]
fn run_rejects_traversal_package_name() {
let dir = tempdir().expect("tempdir");
let virtual_store_dir = dir.path().to_path_buf();
let cas_paths: HashMap<String, std::path::PathBuf> = HashMap::new();
let logged_methods = AtomicU8::new(0);
let snapshot = SnapshotEntry::default();
let package_key: PackageKey =
"../../escaped@1.0.0".parse().expect("parse traversal snapshot key");
let layout = crate::VirtualStoreLayout::legacy(
virtual_store_dir,
pacquet_config::default_virtual_store_dir_max_length() as usize,
);
let skipped = crate::SkippedSnapshots::default();
let result = CreateVirtualDirBySnapshot {
layout: &layout,
cas_paths: &cas_paths,
import_method: PackageImportMethod::Hardlink,
logged_methods: &logged_methods,
requester: "/proj",
package_id: "../../escaped@1.0.0",
package_key: &package_key,
snapshot: &snapshot,
skipped: &skipped,
removed_aliases: &[],
link_concurrency_probe: None,
}
.run::<pacquet_reporter::SilentReporter>();
assert!(
matches!(result, Err(crate::CreateVirtualDirError::InvalidAlias(_))),
"a traversal package name must be rejected before extraction; got {result:?}",
);
}
/// A warm reinstall that drops a child dependency unlinks the stale
/// symlink (and its now-empty `@scope` directory) while leaving the
/// children it still depends on in place.

View File

@@ -441,6 +441,7 @@ pub fn symlink_hoisted_dependencies(
public_hoisted_modules_dir: &std::path::Path,
skipped: &std::collections::HashSet<PackageKey>,
) -> Result<(), crate::SymlinkPackageError> {
use crate::safe_join_modules_dir::safe_join_modules_dir;
use rayon::prelude::*;
use std::{
collections::HashSet,
@@ -474,8 +475,16 @@ pub fn symlink_hoisted_dependencies(
continue;
}
let Some(node) = graph.get(node_id) else { continue };
let dep_dir =
Arc::new(layout.slot_dir(node_id).join("node_modules").join(name_to_dir(&node.name)));
// `node.name` originates from the lockfile, so a traversal-shaped
// name is guarded here before it becomes the hoist symlink's
// `<slot>/node_modules/<name>` target.
let dep_dir = Arc::new(
safe_join_modules_dir(
&layout.slot_dir(node_id).join("node_modules"),
&node.name.to_string(),
)
.map_err(crate::SymlinkPackageError::InvalidAlias)?,
);
for (alias, kind) in alias_map {
let target_dir_root: &Path = match kind {
HoistKind::Public => public_hoisted_modules_dir,
@@ -548,17 +557,6 @@ pub fn symlink_hoisted_dependencies(
)
}
/// Render a [`PkgName`] into its on-disk segment under `node_modules`.
/// Scoped names land at `@scope/name`, unscoped at `name`. Mirrors the
/// helper used by the bin linker (kept private to this module to avoid
/// cross-crate dependency churn for one path-join helper).
fn name_to_dir(name: &PkgName) -> std::path::PathBuf {
match &name.scope {
Some(scope) => std::path::PathBuf::from(format!("@{scope}")).join(&name.bare),
None => std::path::PathBuf::from(&name.bare),
}
}
/// Read the existing symlink at `dest` and decide whether it should
/// be replaced. If it already points at `dep_dir`, leave it untouched.
/// If it points inside `package_store_dir` or `internal_pnpm_dir`

View File

@@ -393,6 +393,56 @@ fn symlink_skips_dropped_nodes() {
);
}
/// A hoisted node whose own (lockfile-derived) name is a path traversal
/// would become the `<slot>/node_modules/<name>` symlink target and
/// escape the store. `symlink_hoisted_dependencies` must reject it.
#[test]
fn symlink_rejects_traversal_node_name() {
use crate::VirtualStoreLayout;
use pacquet_lockfile::PkgName;
use tempfile::tempdir;
let dir = tempdir().unwrap();
let virtual_store_dir = dir.path().join("node_modules/.pacquet");
let private_hoisted = virtual_store_dir.join("node_modules");
let public_hoisted = dir.path().join("node_modules");
std::fs::create_dir_all(&virtual_store_dir).unwrap();
let node_key = key("evil", "1.0.0");
let mut hoisted: HashMap<PackageKey, HashMap<String, HoistKind>> = HashMap::new();
hoisted.insert(node_key.clone(), HashMap::from([("evil".to_string(), HoistKind::Private)]));
let mut graph: HashMap<PackageKey, HoistGraphNode> = HashMap::new();
graph.insert(
node_key,
HoistGraphNode {
name: PkgName::parse("../../escaped").unwrap(),
children: HashMap::new(),
has_bin: false,
},
);
let layout = VirtualStoreLayout::legacy(
&virtual_store_dir,
pacquet_config::default_virtual_store_dir_max_length() as usize,
);
let skipped: HashSet<PackageKey> = HashSet::new();
let result = super::symlink_hoisted_dependencies(
&hoisted,
&graph,
&layout,
&private_hoisted,
&public_hoisted,
&skipped,
);
assert!(
matches!(result, Err(crate::SymlinkPackageError::InvalidAlias(_))),
"a traversal hoisted node name must be rejected; got {result:?}",
);
assert!(!dir.path().join("escaped").exists(), "no hoist link may be created outside the store");
}
#[test]
fn private_hoist_with_bins_collected_for_bin_link() {
let (snapshots, packages) = make_lockfile_data(&[

View File

@@ -890,6 +890,19 @@ where
Some(&allow_build_policy),
);
// Reject a lockfile whose dependency names, aliases, or
// virtual-store slots would escape the project or the store once
// joined into a filesystem path. Runs before any materialization
// and before the warm-install skip filter, and unconditionally —
// so it is not bypassed by `trustLockfile`, which disables the
// resolution-verification fan-out where the offline name check
// would otherwise run. The slot-containment half needs the
// install-time `layout`, so it can't live in the verifier crate.
pacquet_lockfile_verification::verify_lockfile_dependency_names(lockfile)
.map_err(InstallFrozenLockfileError::LockfileVerification)?;
crate::validate_virtual_store_slot_containment(snapshots, &layout)
.map_err(InstallFrozenLockfileError::LockfileVerification)?;
// The frozen path runs no resolve-time prefetcher, so the warm
// batch owns package-status progress for store hits. An empty set
// leaves every warm package reported as `found_in_store`.

View File

@@ -1,6 +1,8 @@
use crate::{
ImportIndexedDirError, ImportIndexedDirOpts, SymlinkPackageError, import_indexed_dir,
retry_config::retry_opts_from_config, symlink_package,
retry_config::retry_opts_from_config,
safe_join_modules_dir::{InvalidDependencyAliasError, safe_join_modules_dir},
symlink_package,
};
use derive_more::{Display, Error};
use miette::Diagnostic;
@@ -89,6 +91,14 @@ pub enum InstallPackageFromRegistryError {
ImportIndexedDir(#[error(source)] ImportIndexedDirError),
SymlinkPackage(#[error(source)] SymlinkPackageError),
/// The resolved package name is not a valid npm package name. For a
/// tarball, git, or file dependency the name comes from the fetched
/// `package.json`, so a traversal-shaped name would let the import
/// escape the virtual store's `node_modules`. Surfaces pnpm's
/// `ERR_PNPM_INVALID_DEPENDENCY_NAME`.
#[diagnostic(transparent)]
InvalidAlias(#[error(source)] InvalidDependencyAliasError),
/// The resolver produced a resolution shape the npm install path
/// can't materialize (today: anything other than a tarball
/// resolution carrying an integrity hash). Surfaces with a
@@ -138,7 +148,11 @@ impl InstallPackageFromRegistry<'_> {
// key (`alias`) so an npm-alias entry and its non-aliased
// counterpart can coexist in the same parent, both pointing
// at the same registry-named subdirectory inside `slot_dir`.
let save_path = slot_dir.join("node_modules").join(&real_name);
// `real_name` is untrusted for tarball/git/file deps (read from
// the fetched manifest), so the join is guarded against a
// traversal-shaped name escaping `node_modules`.
let save_path = safe_join_modules_dir(&slot_dir.join("node_modules"), &real_name)
.map_err(InstallPackageFromRegistryError::InvalidAlias)?;
let symlink_path = node_modules_dir.join(alias);

View File

@@ -534,3 +534,79 @@ async fn install_returns_unsupported_resolution_when_name_ver_missing() {
drop((store_dir, modules_dir, virtual_store_dir));
}
/// A tarball/git/file dep whose manifest `name` is a path traversal
/// must be refused before the import writes anything, so a malicious
/// `package.json` can't escape the virtual store's `node_modules` and
/// overwrite files at an attacker-chosen location. The guard fires
/// before the tarball download, so no network access is needed.
#[tokio::test]
async fn install_rejects_traversal_manifest_name() {
let store_dir = tempdir().unwrap();
let modules_dir = tempdir().unwrap();
let virtual_store_dir = tempdir().unwrap();
let config = create_config(store_dir.path(), modules_dir.path(), virtual_store_dir.path());
let config: &'static Config = config.pipe(Box::new).pipe(Box::leak);
let http_client = Arc::new(ThrottledClient::new_for_installs());
let verified_files_cache = SharedVerifiedFilesCache::default();
let logged_methods = AtomicU8::new(0);
let traversal_name = "@x/../../../../../../OUTSIDE";
let resolution = ResolveResult {
id: "https://example.com/foo.tar.gz".into(),
name_ver: None,
latest: None,
published_at: None,
manifest: Some(Arc::new(serde_json::json!({
"name": traversal_name,
"version": "1.0.0",
}))),
resolution: LockfileResolution::Tarball(TarballResolution {
tarball: "https://example.com/foo.tar.gz".to_string(),
integrity: None,
git_hosted: Some(true),
path: None,
}),
resolved_via: "git-repository".to_string(),
normalized_bare_specifier: None,
alias: Some("bar".to_string()),
policy_violation: None,
};
let slot_dir = virtual_store_dir.path().join("bar@1.0.0");
let result = InstallPackageFromRegistry {
tarball_mem_cache: &Default::default(),
config,
http_client: &http_client,
store_index: None,
store_index_writer: None,
verified_files_cache: &verified_files_cache,
prefetched_cas_paths: None,
logged_methods: &logged_methods,
requester: "",
alias: "bar",
resolution: &resolution,
node_modules_dir: modules_dir.path(),
slot_dir: &slot_dir,
first_visit: true,
}
.run::<SilentReporter>()
.await;
match result {
Err(InstallPackageFromRegistryError::InvalidAlias(err)) => {
assert_eq!(err.alias, traversal_name);
}
other => panic!("expected InvalidAlias, got {other:?}"),
}
// The traversal must not have materialized anything outside the
// slot's `node_modules`.
assert!(!virtual_store_dir.path().join("OUTSIDE").exists());
assert!(!slot_dir.join("node_modules").join("OUTSIDE").exists());
drop((store_dir, modules_dir, virtual_store_dir));
}

View File

@@ -268,17 +268,15 @@ pub enum InstallWithFreshLockfileError {
LinkVirtualStoreBins(#[error(source)] LinkVirtualStoreBinsError),
/// The resolver chain failed for at least one dependency. The
/// inner message carries the boxed error's `Display`.
/// diagnostic is forwarded transparently so a canonical inner code
/// (e.g. a traversal name's `ERR_PNPM_INVALID_DEPENDENCY_NAME`)
/// reaches the CLI unchanged. The `Display` still interpolates the
/// inner error so consumers that stringify the top-level error
/// (e.g. pnpr's `resolve.rs`, which forwards `err.to_string()` over
/// the wire) keep the detail.
#[display("Failed to resolve dependency tree: {_0}")]
#[diagnostic(code(pacquet_package_manager::resolve_dependency_tree))]
ResolveDependencyTree(#[error(not(source))] ResolveDependencyTreeError),
/// The hoist-loop orchestrator failed. Wraps the tree-walk error
/// (the only failure source today) plus any future orchestrator-
/// specific failures.
#[display("Failed to resolve importer: {_0}")]
#[diagnostic(code(pacquet_package_manager::resolve_importer))]
ResolveImporter(#[error(not(source))] ResolveImporterError),
#[diagnostic(transparent)]
ResolveDependencyTree(#[error(source)] ResolveDependencyTreeError),
/// `minimumReleaseAgeExclude` patterns rejected at compile time.
/// Surfaced as `ERR_PNPM_INVALID_MINIMUM_RELEASE_AGE_EXCLUDE`.
@@ -1188,7 +1186,9 @@ impl<DependencyGroupList> InstallWithFreshLockfile<'_, DependencyGroupList> {
},
)
.await
.map_err(InstallWithFreshLockfileError::ResolveImporter)?;
.map_err(|ResolveImporterError::Resolve(err)| {
InstallWithFreshLockfileError::ResolveDependencyTree(err)
})?;
let total_nodes = workspace_result.peers.graph.len();
for (importer_id, issues) in &workspace_result.peers.peer_dependency_issues_by_importer {
tracing::warn!(

View File

@@ -50,6 +50,7 @@ mod tarball_prefetch;
mod update;
mod update_project_manifest;
mod update_project_manifest_object;
mod validate_lockfile_paths;
mod version_policy;
mod virtual_store_layout;
@@ -98,6 +99,7 @@ pub use tarball_prefetch::*;
pub use update::*;
pub use update_project_manifest::*;
pub use update_project_manifest_object::*;
pub use validate_lockfile_paths::*;
pub use version_policy::*;
pub use virtual_store_layout::*;

View File

@@ -16,7 +16,7 @@ use std::path::{Path, PathBuf};
/// `ERR_PNPM_INVALID_DEPENDENCY_NAME`.
#[derive(Debug, Display, Error, Diagnostic)]
#[display("Refusing to place a dependency under {} with the invalid alias {alias:?}", modules.display())]
#[diagnostic(code(INVALID_DEPENDENCY_NAME))]
#[diagnostic(code(ERR_PNPM_INVALID_DEPENDENCY_NAME))]
pub struct InvalidDependencyAliasError {
pub modules: PathBuf,
#[error(not(source))]

View File

@@ -1,3 +1,4 @@
use crate::safe_join_modules_dir::InvalidDependencyAliasError;
use derive_more::{Display, Error};
use miette::Diagnostic;
use pacquet_fs::{ForceSymlinkOutcome, force_symlink_dir};
@@ -23,6 +24,13 @@ pub enum SymlinkPackageError {
#[error(source)]
error: io::Error,
},
/// A hoisted package's name is not a valid npm package name, so the
/// `<slot>/node_modules/<name>` target the hoist symlink would point
/// at could escape the slot's `node_modules`. Surfaces pnpm's
/// `ERR_PNPM_INVALID_DEPENDENCY_NAME`.
#[diagnostic(transparent)]
InvalidAlias(#[error(source)] InvalidDependencyAliasError),
}
/// Create a `node_modules/<name>` symlink for a direct dependency.

View File

@@ -0,0 +1,52 @@
//! Reject a lockfile whose virtual-store slots would escape the store
//! root once materialized.
//!
//! Dependency-name validation lives in the lockfile verifier
//! ([`pacquet_lockfile_verification::verify_lockfile_dependency_names`]),
//! which the install runs unconditionally. This module covers the one
//! escape that name validation alone can't: the global-virtual-store
//! slot path inserts the package name and the version-derived segment as
//! raw path components (unlike the legacy flat name, which `/`-escapes),
//! so a traversal in the version escapes the store even when the name is
//! valid. The check needs the install-time [`VirtualStoreLayout`], which
//! is why it lives here rather than in the verifier crate.
use crate::VirtualStoreLayout;
use pacquet_fs::is_subdir;
use pacquet_lockfile::{PackageKey, SnapshotEntry};
use pacquet_lockfile_verification::VerifyError;
use std::collections::{BTreeSet, HashMap};
/// Reject the install when any snapshot's computed virtual-store slot
/// resolves outside the store root. The whole `snapshots` map is
/// scanned — not just the survivors of the warm-install skip filter —
/// so a poisoned snapshot that would be skipped as unchanged is still
/// rejected before any directory is created.
///
/// Surfaces [`VerifyError::InvalidDependencyAlias`]
/// (`ERR_PNPM_INVALID_DEPENDENCY_NAME`), the same code the name check
/// raises, listing every offending snapshot key.
pub fn validate_virtual_store_slot_containment(
snapshots: Option<&HashMap<PackageKey, SnapshotEntry>>,
layout: &VirtualStoreLayout,
) -> Result<(), VerifyError> {
let Some(snapshots) = snapshots else {
return Ok(());
};
let mut escaped: BTreeSet<String> = BTreeSet::new();
for key in snapshots.keys() {
// Lexical containment: the slot does not exist yet, so this must
// not touch the filesystem.
if !is_subdir(layout.package_store_dir(), &layout.slot_dir(key)) {
escaped.insert(key.to_string());
}
}
if escaped.is_empty() {
return Ok(());
}
let escaped: Vec<String> = escaped.into_iter().collect();
Err(VerifyError::invalid_dependency_aliases(&escaped))
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,50 @@
use super::validate_virtual_store_slot_containment;
use crate::VirtualStoreLayout;
use miette::Diagnostic;
use pacquet_lockfile::{PackageKey, SnapshotEntry};
use std::{collections::HashMap, path::PathBuf};
fn assert_invalid_dependency_name_code(err: &pacquet_lockfile_verification::VerifyError) {
let code = err.code().map(|code| code.to_string());
assert_eq!(code.as_deref(), Some("ERR_PNPM_INVALID_DEPENDENCY_NAME"));
}
#[test]
fn accepts_snapshots_whose_slots_stay_in_the_store() {
let layout = VirtualStoreLayout::legacy(
PathBuf::from("/project/node_modules/.pnpm"),
pacquet_config::default_virtual_store_dir_max_length() as usize,
);
let mut snapshots = HashMap::new();
snapshots.insert("@scope/foo@1.2.3".parse::<PackageKey>().unwrap(), SnapshotEntry::default());
snapshots.insert("bar@4.5.6".parse::<PackageKey>().unwrap(), SnapshotEntry::default());
validate_virtual_store_slot_containment(Some(&snapshots), &layout)
.expect("contained slots must pass");
}
#[test]
fn rejects_a_global_virtual_store_version_escape() {
// Under the global virtual store the slot path inserts the version
// segment as a raw path component (unlike the legacy flat name, which
// escapes `/`). A traversal-bearing version escapes the store root
// even though the package name itself is valid, so the containment
// check — not the name check — is what rejects it.
let key: PackageKey = "evil@../../../escaped".parse().expect("parse escaping version key");
let mut config = pacquet_config::Config::new();
config.enable_global_virtual_store = true;
config.global_virtual_store_dir = PathBuf::from("/store/links");
let mut snapshots = HashMap::new();
snapshots.insert(key.clone(), SnapshotEntry::default());
let layout = VirtualStoreLayout::new(&config, None, Some(&snapshots), None, None);
assert!(
!pacquet_fs::is_subdir(layout.package_store_dir(), &layout.slot_dir(&key)),
"the crafted slot must actually escape the store for this test to be meaningful",
);
let err = validate_virtual_store_slot_containment(Some(&snapshots), &layout)
.expect_err("a slot that escapes the store root must be rejected");
assert_invalid_dependency_name_code(&err);
assert!(err.to_string().contains("evil@"), "offender listed: {err}");
}

View File

@@ -215,11 +215,11 @@ pub enum ResolveDependencyTreeError {
/// A dependency alias contained a path-separator segment that would
/// escape the intended `node_modules` directory when joined onto a
/// modules path, raised with the `INVALID_DEPENDENCY_NAME` code.
/// modules path, raised with the `ERR_PNPM_INVALID_DEPENDENCY_NAME` code.
#[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))]
#[diagnostic(code(ERR_PNPM_INVALID_DEPENDENCY_NAME))]
InvalidDependencyName {
#[error(not(source))]
parent: String,

15
pnpm-lock.yaml generated
View File

@@ -3285,6 +3285,9 @@ importers:
'@pnpm/deps.path':
specifier: workspace:*
version: link:../path
'@pnpm/fs.symlink-dependency':
specifier: workspace:*
version: link:../../fs/symlink-dependency
'@pnpm/hooks.types':
specifier: workspace:*
version: link:../../hooks/types
@@ -3316,6 +3319,9 @@ importers:
specifier: 'catalog:'
version: '@pnpm/ramda@0.28.1'
devDependencies:
'@jest/globals':
specifier: 'catalog:'
version: 30.4.1
'@pnpm/deps.graph-builder':
specifier: workspace:*
version: 'link:'
@@ -5986,6 +5992,9 @@ importers:
'@pnpm/fetching.pick-fetcher':
specifier: workspace:*
version: link:../../fetching/pick-fetcher
'@pnpm/fs.symlink-dependency':
specifier: workspace:*
version: link:../../fs/symlink-dependency
'@pnpm/hooks.types':
specifier: workspace:*
version: link:../../hooks/types
@@ -6294,6 +6303,9 @@ importers:
'@pnpm/fs.read-modules-dir':
specifier: workspace:*
version: link:../../fs/read-modules-dir
'@pnpm/fs.symlink-dependency':
specifier: workspace:*
version: link:../../fs/symlink-dependency
'@pnpm/installing.deps-resolver':
specifier: workspace:*
version: link:../deps-resolver
@@ -7054,6 +7066,9 @@ importers:
'@pnpm/deps.path':
specifier: workspace:*
version: link:../../deps/path
'@pnpm/fs.symlink-dependency':
specifier: workspace:*
version: link:../../fs/symlink-dependency
'@pnpm/lockfile.fs':
specifier: workspace:*
version: link:../fs

View File

@@ -24,10 +24,11 @@
"!*.map"
],
"scripts": {
"lint": "eslint \"src/**/*.ts\"",
"test": "pn compile",
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
"test": "pn compile && pn .test",
"prepublishOnly": "tsgo --build",
"compile": "tsgo --build && pn lint --fix"
"compile": "tsgo --build && pn lint --fix",
".test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169\" jest"
},
"dependencies": {
"@pnpm/config.package-is-installable": "workspace:*",
@@ -35,6 +36,7 @@
"@pnpm/core-loggers": "workspace:*",
"@pnpm/deps.graph-hasher": "workspace:*",
"@pnpm/deps.path": "workspace:*",
"@pnpm/fs.symlink-dependency": "workspace:*",
"@pnpm/hooks.types": "workspace:*",
"@pnpm/installing.modules-yaml": "workspace:*",
"@pnpm/lockfile.fs": "workspace:*",
@@ -50,6 +52,7 @@
"@pnpm/logger": "catalog:"
},
"devDependencies": {
"@jest/globals": "catalog:",
"@pnpm/deps.graph-builder": "workspace:*",
"@pnpm/logger": "workspace:*",
"@types/ramda": "catalog:"

View File

@@ -7,6 +7,7 @@ import {
progressLogger,
} from '@pnpm/core-loggers'
import * as dp from '@pnpm/deps.path'
import { safeJoinModulesDir } from '@pnpm/fs.symlink-dependency'
import type { IncludedDependencies } from '@pnpm/installing.modules-yaml'
import type { LockfileObject, LockfileResolution } from '@pnpm/lockfile.fs'
import {
@@ -230,7 +231,11 @@ async function buildGraphFromPackages (
const depIntegrityIsUnchanged = isIntegrityEqual(pkgSnapshot.resolution, currentPackages[depPath]?.resolution)
const modules = path.join(dirInVirtualStore, 'node_modules')
const dir = path.join(modules, pkgName)
// `pkgName` is reconstructed from the (attacker-controllable) lockfile
// depPath key via `dp.parse`, which does no validation. Contain it here so
// a traversal name (e.g. `../../../tmp/x`) can't make the package import
// escape the virtual store. Mirrors the guard on the hoisted linker.
const dir = safeJoinModulesDir(modules, pkgName)
locationByDepPath[depPath] = dir
// Track directory deps for injected workspace packages
if (isDirectoryDep) {

View File

@@ -0,0 +1,124 @@
/// <reference path="../../../__typings__/index.d.ts" />
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { expect, test } from '@jest/globals'
import { depPathToFilename } from '@pnpm/deps.path'
import type { LockfileObject } from '@pnpm/lockfile.fs'
import { lockfileToDepGraph, type LockfileToDepGraphOptions } from '../src/lockfileToDepGraph.js'
// A crafted lockfile whose `packages` depPath *key* carries a path-traversal
// (or reserved) name. The isolated (virtual-store) linker reconstructs the
// package name from that key via `dp.parse` and joins it onto the virtual
// store to form the package's install directory, so this is the shape an
// attacker who can ship a lockfile would use to make `pnpm install` write
// package content outside the store (GHSA-c59q-g84q-2gj5).
function craftedLockfile (name: string): LockfileObject {
return {
lockfileVersion: '9.0',
importers: {
'.': {
dependencies: { 'legit-name': `${name}@1.0.0` },
specifiers: { 'legit-name': '1.0.0' },
},
},
packages: {
[`${name}@1.0.0`]: {
resolution: { integrity: 'sha512-deadbeef' },
},
},
} as unknown as LockfileObject
}
// `force: true` skips the installability check so the walk reaches the name
// sink directly; the store controller throws if touched, proving the name is
// rejected before any fetch or filesystem work.
function graphOpts (lockfileDir: string): LockfileToDepGraphOptions {
const unreachable = (name: string) => () => {
throw new Error(`${name} must not be reached for a rejected package name`)
}
return {
autoInstallPeers: false,
engineStrict: false,
force: true,
importerIds: ['.'],
include: { dependencies: true, devDependencies: true, optionalDependencies: true },
ignoreScripts: false,
lockfileDir,
nodeVersion: process.version,
pnpmVersion: '0.0.0',
registries: { default: 'http://localhost/' },
sideEffectsCacheRead: false,
skipped: new Set(),
storeController: {
fetchPackage: unreachable('fetchPackage'),
getFilesIndexFilePath: unreachable('getFilesIndexFilePath'),
},
storeDir: path.join(lockfileDir, 'store'),
globalVirtualStoreDir: path.join(lockfileDir, 'store', 'v10'),
virtualStoreDir: path.join(lockfileDir, 'node_modules', '.pnpm'),
virtualStoreDirMaxLength: 120,
} as unknown as LockfileToDepGraphOptions
}
test.each([
'../../../escape',
'@scope/../../escape',
'.bin',
'.pnpm',
'node_modules',
])('lockfileToDepGraph rejects package name %p', async (name) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-builder-'))
await expect(
lockfileToDepGraph(craftedLockfile(name), null, graphOpts(dir))
).rejects.toThrow(expect.objectContaining({ code: 'ERR_PNPM_INVALID_DEPENDENCY_NAME' }))
})
test('lockfileToDepGraph does not create a directory outside the virtual store for a traversal name', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-builder-'))
const name = '../../../escape'
const opts = graphOpts(dir)
// Reconstruct the exact install directory the unguarded code would derive:
// `depPathToFilename` folds the depPath key into a single (contained) store
// subdir, but the separately-parsed `pkgName` is joined onto its
// `node_modules` raw — that raw join is what escapes.
const dirInVirtualStore = path.join(opts.virtualStoreDir, depPathToFilename(`${name}@1.0.0`, opts.virtualStoreDirMaxLength))
const escaped = path.join(dirInVirtualStore, 'node_modules', name)
// Guard against the test asserting on a non-escaping path: the derived
// directory must genuinely fall outside the virtual store.
expect(escaped.startsWith(opts.virtualStoreDir + path.sep)).toBe(false)
await expect(
lockfileToDepGraph(craftedLockfile(name), null, opts)
).rejects.toThrow(expect.objectContaining({ code: 'ERR_PNPM_INVALID_DEPENDENCY_NAME' }))
expect(fs.existsSync(escaped)).toBe(false)
})
test('lockfileToDepGraph rejects a global-virtual-store slot whose version escapes the store', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-builder-'))
// A valid package name but a traversal *version*. The global-virtual-store
// slot path `@/<name>/<version>/<hash>` inserts the version as a raw path
// segment, so it escapes the store root even though `safeJoinModulesDir`
// accepts the (valid) inner package name — the name guard alone can't catch
// this, only the slot containment can.
const lockfile = {
lockfileVersion: '9.0',
importers: {
'.': {
dependencies: { 'legit-name': 'foo@1.0.0' },
specifiers: { 'legit-name': '1.0.0' },
},
},
packages: {
'foo@1.0.0': {
resolution: { integrity: 'sha512-deadbeef' },
version: '../../../../escape',
},
},
} as unknown as LockfileObject
const opts = { ...graphOpts(dir), enableGlobalVirtualStore: true } as unknown as LockfileToDepGraphOptions
await expect(
lockfileToDepGraph(lockfile, null, opts)
).rejects.toThrow(expect.objectContaining({ code: 'ERR_PNPM_INVALID_DEPENDENCY_NAME' }))
})

View File

@@ -0,0 +1,18 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "../node_modules/.test.lib",
"rootDir": "..",
"isolatedModules": true
},
"include": [
"**/*.ts",
"../../../__typings__/**/*.d.ts"
],
"references": [
{
"path": ".."
}
]
}

View File

@@ -24,6 +24,9 @@
{
"path": "../../core/types"
},
{
"path": "../../fs/symlink-dependency"
},
{
"path": "../../hooks/types"
},

View File

@@ -281,10 +281,26 @@ export function calcGlobalVirtualStorePathWithSubdeps (
// Scoped: @scope/pkg/version/hash
// Unscoped: @/pkg/version/hash
function formatGlobalVirtualStorePath (name: string, version: string, hexDigest: string): string {
// `version` is lockfile-controlled (`pkgSnapshot.version ?? parsed depPath`)
// and is inserted below as a raw path segment. Every global-virtual-store
// slot path funnels through here, and callers join the result onto
// `globalVirtualStoreDir` before passing it to `importPackage`, so a `..`
// segment in the version would let the slot escape the store root — even
// when the package name itself is valid (the name's own traversal is caught
// downstream by `safeJoinModulesDir`). Reject it at this single choke point.
assertNoPathTraversal(version)
const prefix = name.startsWith('@') ? '' : '@/'
return `${prefix}${name}/${version}/${hexDigest}`
}
function assertNoPathTraversal (version: string): void {
if (version.split(/[/\\]/).includes('..')) {
const error = new Error(`Refusing to build a virtual-store path with the traversal version segment ${JSON.stringify(version)}`) as Error & { code: string }
error.code = 'ERR_PNPM_INVALID_DEPENDENCY_NAME'
throw error
}
}
export interface PkgMetaAndSnapshot extends PkgMeta {
pkgSnapshot: PackageSnapshot
pkgIdWithPatchHash: PkgIdWithPatchHash

View File

@@ -6,6 +6,18 @@ describe('calcLeafGlobalVirtualStorePath', () => {
const path = calcLeafGlobalVirtualStorePath('foo@1.0.0:sha512-abc', 'foo', '1.0.0')
expect(path).toMatch(/^@\/foo\/1\.0\.0\/[a-f0-9]+$/)
})
// The version segment is lockfile-controlled and inserted raw into the slot
// path; a `..` segment would let the slot escape the global virtual store
// root once joined onto `globalVirtualStoreDir`. All GVS slot builders funnel
// through `formatGlobalVirtualStorePath`, so the rejection happens here.
it.each(['../../../escape', '..', 'a/../../b', 'x/..'])(
'rejects a traversal version segment %p',
(version) => {
expect(() => calcLeafGlobalVirtualStorePath('foo@1.0.0:sha512-abc', 'foo', version))
.toThrow(expect.objectContaining({ code: 'ERR_PNPM_INVALID_DEPENDENCY_NAME' }))
}
)
})
describe('calcGlobalVirtualStorePathWithSubdeps', () => {

View File

Binary file not shown.

View File

@@ -1,10 +1,13 @@
import { expect, test } from '@jest/globals'
import { addDependenciesToPackage } from '@pnpm/installing.deps-installer'
import { prepareEmpty } from '@pnpm/prepare'
import { fixtures } from '@pnpm/test-fixtures'
import { REGISTRY_MOCK_PORT } from '@pnpm/testing.registry-mock'
import { testDefaults } from '../utils/index.js'
const f = fixtures(import.meta.dirname)
test('tarball from npm registry', async () => {
const project = prepareEmpty()
@@ -32,3 +35,14 @@ test('tarballs from GitHub (is-negative)', async () => {
project.has('is-negative')
})
// A tarball dependency's own manifest `name` is used as the directory name for
// the package inside the virtual store. A traversal name such as
// `@x/../../../<path>` must not escape the store and write outside node_modules.
test('a tarball dependency whose manifest name is a path traversal is rejected', async () => {
prepareEmpty()
await expect(
addDependenciesToPackage({}, [`file:${f.find('pkg-with-path-traversal-name.tgz')}`], testDefaults())
).rejects.toThrow('Refusing to place a dependency')
})

View File

@@ -42,6 +42,7 @@
"@pnpm/deps.peer-range": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/fetching.pick-fetcher": "workspace:*",
"@pnpm/fs.symlink-dependency": "workspace:*",
"@pnpm/hooks.types": "workspace:*",
"@pnpm/lockfile.preferred-versions": "workspace:*",
"@pnpm/lockfile.pruner": "workspace:*",

View File

@@ -7,6 +7,7 @@ import {
import { findRuntimeNodeVersion, iterateHashedGraphNodes } from '@pnpm/deps.graph-hasher'
import { isRuntimeDepPath } from '@pnpm/deps.path'
import { PnpmError } from '@pnpm/error'
import { safeJoinModulesDir } from '@pnpm/fs.symlink-dependency'
import type {
LockfileObject,
ProjectSnapshot,
@@ -611,7 +612,7 @@ function extendGraph (
const node = graph[depPath]
Object.assign(node, {
modules,
dir: path.join(modules, node.name),
dir: safeJoinModulesDir(modules, node.name),
})
}
return graph

View File

@@ -1,6 +1,7 @@
import path from 'node:path'
import { createPeerDepGraphHash, depPathToFilename, parseDepPath, type PeerId } from '@pnpm/deps.path'
import { safeJoinModulesDir } from '@pnpm/fs.symlink-dependency'
import type {
DepPath,
ParentPackages,
@@ -703,7 +704,7 @@ async function resolvePeersOfNode<T extends PartialResolvedPackage> (
const peerDependencies = { ...resolvedPackage.peerDependencies }
if (!ctx.depGraph[depPath] || ctx.depGraph[depPath].depth > node.depth) {
const modules = path.join(ctx.virtualStoreDir, depPathToFilename(depPath, ctx.virtualStoreDirMaxLength), 'node_modules')
const dir = path.join(modules, resolvedPackage.name)
const dir = safeJoinModulesDir(modules, resolvedPackage.name)
const transitivePeerDependencies = new Set<string>()
for (const unknownPeer of allResolvedPeers.keys()) {

View File

@@ -45,6 +45,9 @@
{
"path": "../../fetching/pick-fetcher"
},
{
"path": "../../fs/symlink-dependency"
},
{
"path": "../../hooks/types"
},

View File

@@ -41,6 +41,7 @@
"@pnpm/deps.graph-hasher": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/fs.read-modules-dir": "workspace:*",
"@pnpm/fs.symlink-dependency": "workspace:*",
"@pnpm/installing.deps-resolver": "workspace:*",
"@pnpm/lockfile.fs": "workspace:*",
"@pnpm/lockfile.pruner": "workspace:*",

View File

@@ -7,6 +7,7 @@ import { installingConfigDepsLogger, skippedOptionalDependencyLogger } from '@pn
import { calcGlobalVirtualStorePathWithSubdeps, calcLeafGlobalVirtualStorePath } from '@pnpm/deps.graph-hasher'
import { PnpmError } from '@pnpm/error'
import { readModulesDir } from '@pnpm/fs.read-modules-dir'
import { safeJoinModulesDir } from '@pnpm/fs.symlink-dependency'
import { type EnvLockfile, readEnvLockfile } from '@pnpm/lockfile.fs'
import { getNpmTarballUrl } from '@pnpm/resolving.tarball-url'
import type { StoreController } from '@pnpm/store.controller'
@@ -292,7 +293,7 @@ async function installOptionalSubdeps (opts: InstallOptionalSubdepsOpts): Promis
await Promise.all(compatibleSubdeps.map(async (subdep) => {
const subdepFullPkgId = `${subdep.name}@${subdep.version}:${subdep.resolution.integrity}`
const subdepRelPath = calcLeafGlobalVirtualStorePath(subdepFullPkgId, subdep.name, subdep.version)
const subdepDirInGlobalVirtualStore = path.join(opts.globalVirtualStoreDir, subdepRelPath, 'node_modules', subdep.name)
const subdepDirInGlobalVirtualStore = safeJoinModulesDir(path.join(opts.globalVirtualStoreDir, subdepRelPath, 'node_modules'), subdep.name)
if (!fs.existsSync(path.join(subdepDirInGlobalVirtualStore, 'package.json'))) {
opts.reportStarted()
const { fetching } = await opts.store.fetchPackage({
@@ -310,7 +311,7 @@ async function installOptionalSubdeps (opts: InstallOptionalSubdepsOpts): Promis
filesResponse,
})
}
const linkPath = path.join(opts.parentNodeModulesDir, subdep.name)
const linkPath = safeJoinModulesDir(opts.parentNodeModulesDir, subdep.name)
if (await symlinkPointsTo(linkPath, subdepDirInGlobalVirtualStore)) {
return
}

View File

@@ -39,6 +39,9 @@
{
"path": "../../fs/read-modules-dir"
},
{
"path": "../../fs/symlink-dependency"
},
{
"path": "../../lockfile/fs"
},

View File

@@ -34,6 +34,7 @@
},
"dependencies": {
"@pnpm/deps.path": "workspace:*",
"@pnpm/fs.symlink-dependency": "workspace:*",
"@pnpm/lockfile.fs": "workspace:*",
"@pnpm/lockfile.utils": "workspace:*",
"@pnpm/types": "workspace:*",

View File

@@ -2,6 +2,7 @@ import { promises as fs } from 'node:fs'
import path from 'node:path'
import { depPathToFilename, refToRelative } from '@pnpm/deps.path'
import { safeJoinModulesDir } from '@pnpm/fs.symlink-dependency'
import type { LockfileObject } from '@pnpm/lockfile.fs'
import {
nameVerFromPkgSnapshot,
@@ -101,13 +102,16 @@ export function lockfileToPackageRegistry (
packageRegistry.set(name, packageStore)
}
// Seems like this field should always contain a relative path
let packageLocation = normalizePath(path.relative(opts.lockfileDir, path.join(
// Seems like this field should always contain a relative path.
// `name` is reconstructed from the (attacker-controllable) lockfile depPath
// key via `dp.parse`, which does no validation, so contain it here to keep
// the PnP resolver map from pointing outside the virtual store.
const pkgModulesDir = path.join(
opts.virtualStoreDir,
depPathToFilename(relDepPath, opts.virtualStoreDirMaxLength),
'node_modules',
name
)))
'node_modules'
)
let packageLocation = normalizePath(path.relative(opts.lockfileDir, safeJoinModulesDir(pkgModulesDir, name)))
if (!packageLocation.startsWith('../')) {
packageLocation = `./${packageLocation}`
}

View File

@@ -2,8 +2,9 @@
import path from 'node:path'
import { expect, test } from '@jest/globals'
import type { LockfileObject } from '@pnpm/lockfile.fs'
import { dependenciesGraphToPackageMap, lockfileToPackageMap, lockfileToPackageRegistry } from '@pnpm/lockfile.to-pnp'
import type { DepPath, ProjectId } from '@pnpm/types'
import type { DepPath, ProjectId, Registries } from '@pnpm/types'
test('lockfileToPackageRegistry', () => {
const packageRegistry = lockfileToPackageRegistry({
@@ -701,3 +702,33 @@ test('lockfileToPackageRegistry packages that have peer deps', () => {
],
])
})
// A `packages` depPath *key* whose name portion is a path-traversal makes the
// PnP `packageLocation` (built by joining that name onto the virtual store)
// point outside the store. The name must be rejected so a tampered lockfile
// can't aim the `.pnp.cjs` resolver map at arbitrary paths (GHSA-c59q-g84q-2gj5).
test('lockfileToPackageRegistry rejects a package name with path-traversal', () => {
const lockfile = {
lockfileVersion: '9.0',
importers: {
['.' as ProjectId]: {
dependencies: { 'legit-name': '../../../escape@1.0.0' },
specifiers: { 'legit-name': '1.0.0' },
},
},
packages: {
['../../../escape@1.0.0' as DepPath]: {
resolution: { integrity: 'sha512-deadbeef' },
},
},
} as unknown as LockfileObject
expect(() =>
lockfileToPackageRegistry(lockfile, {
importerNames: {},
lockfileDir: '/home/user/project',
virtualStoreDir: '/home/user/project/node_modules/.pnpm',
virtualStoreDirMaxLength: 120,
registries: { default: 'https://registry.npmjs.org/' } as Registries,
})
).toThrow(expect.objectContaining({ code: 'ERR_PNPM_INVALID_DEPENDENCY_NAME' }))
})

View File

@@ -18,6 +18,9 @@
{
"path": "../../deps/path"
},
{
"path": "../../fs/symlink-dependency"
},
{
"path": "../fs"
},