From 8311f44dae43057710470846875cf86b952e10bd Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Wed, 10 Jun 2026 11:56:14 -0600 Subject: [PATCH] fix: Confine prune patch paths --- crates/turborepo-lib/src/commands/prune.rs | 81 +++++++++++++++++-- crates/turborepo/tests/prune_test.rs | 92 +++++++++++++++++++++- 2 files changed, 166 insertions(+), 7 deletions(-) diff --git a/crates/turborepo-lib/src/commands/prune.rs b/crates/turborepo-lib/src/commands/prune.rs index e84d5f8a17fca..b695337c898d6 100644 --- a/crates/turborepo-lib/src/commands/prune.rs +++ b/crates/turborepo-lib/src/commands/prune.rs @@ -10,8 +10,8 @@ use globwalk::{ValidatedGlob, WalkType}; use miette::Diagnostic; use tracing::trace; use turbopath::{ - AbsoluteSystemPathBuf, AnchoredSystemPath, AnchoredSystemPathBuf, RelativeUnixPath, - RelativeUnixPathBuf, + AbsoluteSystemPath, AbsoluteSystemPathBuf, AnchoredSystemPath, AnchoredSystemPathBuf, + RelativeUnixPath, RelativeUnixPathBuf, }; use turborepo_repository::{ package_graph::{self, PackageGraph, PackageName, PackageNode}, @@ -54,6 +54,10 @@ pub enum Error { NoWorkspaceSpecified, #[error("Invalid scope. Package with name {0} in `package.json` not found.")] MissingWorkspace(PackageName), + #[error( + "Invalid patched dependency path `{0}`: path escapes the repository or output directory" + )] + InvalidPatchPath(RelativeUnixPathBuf), #[error("Cannot prune without parsed lockfile.")] MissingLockfile, #[error("Unable to read config: {0}")] @@ -304,10 +308,7 @@ pub async fn prune( if !original_patches.is_empty() { for patch in &pruned_patches { - prune.copy_file( - &patch.to_anchored_system_path_buf(), - Some(CopyDestination::Docker), - )?; + prune.copy_patch_file(patch)?; } // Prune pnpm-workspace.yaml's patchedDependencies so it only @@ -392,9 +393,33 @@ fn collect_patch_paths( patches.sort(); patches.dedup(); + validate_patch_source_paths(repo_root, &patches)?; Ok(patches) } +fn validate_patch_source_paths( + repo_root: &AbsoluteSystemPath, + patches: &[RelativeUnixPathBuf], +) -> Result<(), Error> { + let repo_root_realpath = repo_root.to_realpath()?; + + for patch in patches { + let patch_path = repo_root.join_unix_path(patch); + if !patch_path.starts_with(repo_root.as_std_path()) { + return Err(Error::InvalidPatchPath(patch.clone())); + } + + if patch_path.try_exists()? { + let patch_realpath = patch_path.to_realpath()?; + if !patch_realpath.starts_with(repo_root_realpath.as_std_path()) { + return Err(Error::InvalidPatchPath(patch.clone())); + } + } + } + + Ok(()) +} + fn package_json_patch_paths( package_json: &PackageJson, patch_keys: &[String], @@ -569,6 +594,50 @@ impl<'a> Prune<'a> { Ok(()) } + fn copy_patch_file(&self, patch: &RelativeUnixPathBuf) -> Result<(), Error> { + self.validate_patch_destination_path(patch, &self.full_directory)?; + if self.docker { + self.validate_patch_destination_path(patch, &self.docker_directory())?; + } + + self.copy_file( + &patch.to_anchored_system_path_buf(), + Some(CopyDestination::Docker), + ) + } + + fn validate_patch_destination_path( + &self, + patch: &RelativeUnixPathBuf, + destination_root: &AbsoluteSystemPath, + ) -> Result<(), Error> { + let destination_root_realpath = destination_root.to_realpath()?; + let patch_path = destination_root.join_unix_path(patch); + + if !patch_path.starts_with(destination_root.as_std_path()) { + return Err(Error::InvalidPatchPath(patch.clone())); + } + + if patch_path.symlink_metadata().is_ok() { + let patch_realpath = patch_path.to_realpath()?; + if !patch_realpath.starts_with(destination_root_realpath.as_std_path()) { + return Err(Error::InvalidPatchPath(patch.clone())); + } + } + + for ancestor in patch_path.ancestors().skip(1) { + if ancestor.try_exists()? { + let ancestor_realpath = ancestor.to_realpath()?; + if !ancestor_realpath.starts_with(destination_root_realpath.as_std_path()) { + return Err(Error::InvalidPatchPath(patch.clone())); + } + break; + } + } + + Ok(()) + } + fn copy_directory( &self, path: &AnchoredSystemPath, diff --git a/crates/turborepo/tests/prune_test.rs b/crates/turborepo/tests/prune_test.rs index 802b158f6a9fd..391f13dc094ee 100644 --- a/crates/turborepo/tests/prune_test.rs +++ b/crates/turborepo/tests/prune_test.rs @@ -4,7 +4,7 @@ mod common; use std::{fs, path::Path}; -use common::{run_turbo, setup}; +use common::{combined_output, run_turbo, setup}; fn ls_dir(dir: &Path) -> Vec { let mut entries: Vec = fs::read_dir(dir) @@ -81,6 +81,96 @@ fn test_prune_docker() { ); } +#[test] +fn test_prune_rejects_patch_paths_with_parent_dir() { + let tempdir = tempfile::tempdir().unwrap(); + setup::copy_fixture("monorepo_with_root_dep", tempdir.path()).unwrap(); + + let outside_dir = tempfile::tempdir_in(tempdir.path().parent().unwrap()).unwrap(); + let outside_dir_name = outside_dir + .path() + .file_name() + .unwrap() + .to_string_lossy() + .to_string(); + let outside_patch_name = "outside.patch"; + let malicious_patch_path = format!("../{outside_dir_name}/{outside_patch_name}"); + fs::write( + outside_dir.path().join(outside_patch_name), + "outside repo\n", + ) + .unwrap(); + + let lockfile_path = tempdir.path().join("pnpm-lock.yaml"); + let lockfile = fs::read_to_string(&lockfile_path).unwrap(); + fs::write( + &lockfile_path, + lockfile.replace("patches/is-number@7.0.0.patch", &malicious_patch_path), + ) + .unwrap(); + + let package_json_path = tempdir.path().join("package.json"); + let package_json = fs::read_to_string(&package_json_path).unwrap(); + fs::write( + &package_json_path, + package_json.replace("patches/is-number@7.0.0.patch", &malicious_patch_path), + ) + .unwrap(); + + let output = run_turbo(tempdir.path(), &["prune", "web"]); + let combined_output = combined_output(&output); + + assert!(!output.status.success()); + assert!( + combined_output.contains("Invalid patched dependency path"), + "unexpected output: {combined_output}" + ); + assert!( + !tempdir + .path() + .join(&outside_dir_name) + .join(outside_patch_name) + .exists() + ); +} + +#[test] +fn test_prune_allows_patch_paths_with_non_escaping_parent_dir() { + let tempdir = tempfile::tempdir().unwrap(); + setup::copy_fixture("monorepo_with_root_dep", tempdir.path()).unwrap(); + + let patch_path = "patches/../patches/is-number@7.0.0.patch"; + let lockfile_path = tempdir.path().join("pnpm-lock.yaml"); + let lockfile = fs::read_to_string(&lockfile_path).unwrap(); + fs::write( + &lockfile_path, + lockfile.replace("patches/is-number@7.0.0.patch", patch_path), + ) + .unwrap(); + + let package_json_path = tempdir.path().join("package.json"); + let package_json = fs::read_to_string(&package_json_path).unwrap(); + fs::write( + &package_json_path, + package_json.replace("patches/is-number@7.0.0.patch", patch_path), + ) + .unwrap(); + + let output = run_turbo(tempdir.path(), &["prune", "web"]); + + assert!( + output.status.success(), + "prune failed: {}", + combined_output(&output) + ); + assert!( + tempdir + .path() + .join("out/patches/is-number@7.0.0.patch") + .exists() + ); +} + #[test] fn test_prune_docker_creates_bin_stubs() { let tempdir = tempfile::tempdir().unwrap();