From 7c2b6e3c61349a22c33589b0d0dfe867d4275b86 Mon Sep 17 00:00:00 2001 From: Boni Garcia Date: Thu, 11 Jun 2026 10:58:23 +0200 Subject: [PATCH 1/4] fix: prevent path traversal in tar and pkg extraction Add validation to reject archive entries containing ParentDir (..) components in uncompress_tar() and uncompress_pkg(). This prevents malicious archives from writing files outside the intended extraction directory. The shared validation logic is refactored into check_path_traversal() to avoid duplication (DRY principle). - CVE-2025-XXXX: Path Traversal via uncompress_tar - CVE-2025-YYYY: Path Traversal via uncompress_pkg --- rust/src/files.rs | 150 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 149 insertions(+), 1 deletion(-) diff --git a/rust/src/files.rs b/rust/src/files.rs index 51509eac53a13..bf27a637b72d2 100644 --- a/rust/src/files.rs +++ b/rust/src/files.rs @@ -92,6 +92,22 @@ pub fn create_path_if_not_exists(path: &Path) -> Result<(), Error> { Ok(()) } +pub fn check_path_traversal(entry_path: &Path) -> Result<(), Error> { + if entry_path.as_os_str().is_empty() + || entry_path.components().any(|c| { + matches!( + c, + std::path::Component::ParentDir + | std::path::Component::RootDir + | std::path::Component::Prefix(_) + ) + }) + { + return Err(anyhow!("Unsafe entry (path traversal): {:?}", entry_path)); + } + Ok(()) +} + pub fn uncompress( compressed_file: &str, target: &Path, @@ -215,6 +231,7 @@ pub fn uncompress_pkg(compressed_file: &str, target: &Path, log: &Logger) -> Res while let Some(next) = cpio_reader.next() { let entry = next?; let name = entry.name(); + check_path_traversal(Path::new(name))?; let mut file = Vec::new(); cpio_reader.read_to_end(&mut file)?; let target_path_buf = target_path.join(name); @@ -367,7 +384,16 @@ pub fn uncompress_tar(decoder: &mut dyn Read, target: &Path, log: &Logger) -> Re let mut archive = Archive::new(Cursor::new(buffer)); for entry in archive.entries()? { let mut entry_decoder = entry?; - let entry_path: PathBuf = entry_decoder.path()?.iter().skip(1).collect(); + let path = entry_decoder.path()?; + let entry_path: PathBuf = if path.iter().count() > 1 { + path.iter().skip(1).collect() + } else { + path.to_path_buf() + }; + if entry_path.as_os_str().is_empty() { + return Err(anyhow!("Unsafe tar entry (empty path)")); + } + check_path_traversal(&entry_path)?; let entry_target = target.join(entry_path); fs::create_dir_all(entry_target.parent().unwrap())?; entry_decoder.unpack(entry_target)?; @@ -696,3 +722,125 @@ pub fn get_win_file_version(file_path: &str) -> Option { Some(product_version.trim_end_matches('\0').to_string()) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + fn build_tar(entries: &[(&str, &[u8])]) -> Vec { + let mut buffer = Vec::new(); + for (name, contents) in entries { + let mut header = tar::Header::new_gnu(); + header.set_size(contents.len() as u64); + header.set_mode(0o644); + header.set_path("browser/file.txt").unwrap(); + header.set_cksum(); + + let mut header_bytes = header.as_bytes().to_vec(); + let name_bytes = name.as_bytes(); + assert!(name_bytes.len() <= 100, "test tar name too long"); + header_bytes[0..100].fill(0); + header_bytes[0..name_bytes.len()].copy_from_slice(name_bytes); + header_bytes[148..156].fill(b' '); + let checksum: u32 = header_bytes.iter().map(|byte| *byte as u32).sum(); + let checksum_bytes = format!("{:06o}\0 ", checksum); + header_bytes[148..156].copy_from_slice(checksum_bytes.as_bytes()); + + buffer.extend_from_slice(&header_bytes); + buffer.extend_from_slice(contents); + + let remainder = contents.len() % 512; + if remainder != 0 { + buffer.extend_from_slice(&vec![0u8; 512 - remainder]); + } + } + buffer.extend_from_slice(&[0u8; 1024]); + buffer + } + + #[test] + fn check_path_traversal_allows_safe_paths() { + assert!(check_path_traversal(Path::new("browser/file.txt")).is_ok()); + } + + #[test] + fn check_path_traversal_rejects_parent_dir() { + let err = check_path_traversal(Path::new("browser/../../escape.txt")).unwrap_err(); + assert!(err.to_string().contains("Unsafe entry (path traversal)")); + } + + #[test] + fn check_path_traversal_rejects_empty_path() { + let err = check_path_traversal(Path::new("")).unwrap_err(); + assert!(err.to_string().contains("Unsafe entry (path traversal)")); + } + + #[test] + fn check_path_traversal_rejects_absolute_path() { + let err = check_path_traversal(Path::new("/tmp/evil")).unwrap_err(); + assert!(err.to_string().contains("Unsafe entry (path traversal)")); + } + + #[cfg(windows)] + #[test] + fn check_path_traversal_rejects_windows_prefixed_paths() { + let err = check_path_traversal(Path::new(r"C:\evil")).unwrap_err(); + assert!(err.to_string().contains("Unsafe entry (path traversal)")); + + let err = check_path_traversal(Path::new(r"\\server\share\evil")).unwrap_err(); + assert!(err.to_string().contains("Unsafe entry (path traversal)")); + } + + #[test] + fn uncompress_tar_extracts_safe_entry() { + let temp_dir = tempfile::tempdir().unwrap(); + let target = temp_dir.path().join("extract"); + let tar_data = build_tar(&[("browser/file.txt", b"hello")]); + let mut decoder = Cursor::new(tar_data); + let log = Logger::new(); + + uncompress_tar(&mut decoder, &target, &log).unwrap(); + + assert_eq!(fs::read(target.join("file.txt")).unwrap(), b"hello"); + } + + #[test] + fn uncompress_tar_keeps_single_component_entry() { + let temp_dir = tempfile::tempdir().unwrap(); + let target = temp_dir.path().join("extract"); + let tar_data = build_tar(&[("file.txt", b"hello")]); + let mut decoder = Cursor::new(tar_data); + let log = Logger::new(); + + uncompress_tar(&mut decoder, &target, &log).unwrap(); + + assert_eq!(fs::read(target.join("file.txt")).unwrap(), b"hello"); + } + + #[test] + fn uncompress_tar_rejects_path_traversal_entry() { + let temp_dir = tempfile::tempdir().unwrap(); + let target = temp_dir.path().join("extract"); + let escape_path = temp_dir.path().join("escape.txt"); + let tar_data = build_tar(&[("browser/../../escape.txt", b"owned")]); + let mut decoder = Cursor::new(tar_data); + let log = Logger::new(); + + let err = uncompress_tar(&mut decoder, &target, &log).unwrap_err(); + assert!(err.to_string().contains("Unsafe entry (path traversal)")); + assert!(!escape_path.exists()); + } + + #[test] + fn uncompress_tar_rejects_empty_entry_path() { + let temp_dir = tempfile::tempdir().unwrap(); + let target = temp_dir.path().join("extract"); + let tar_data = build_tar(&[("", b"owned")]); + let mut decoder = Cursor::new(tar_data); + let log = Logger::new(); + + let err = uncompress_tar(&mut decoder, &target, &log).unwrap_err(); + assert!(err.to_string().contains("Unsafe tar entry (empty path)")); + } +} From dde040070c10e4174c2286fe52b4933ca1be6ded Mon Sep 17 00:00:00 2001 From: Boni Garcia Date: Thu, 18 Jun 2026 16:56:48 +0200 Subject: [PATCH 2/4] [rust] Removed the duplicate empty-path check from uncompress_tar() --- rust/src/files.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/rust/src/files.rs b/rust/src/files.rs index bf27a637b72d2..606f721ef0787 100644 --- a/rust/src/files.rs +++ b/rust/src/files.rs @@ -390,9 +390,6 @@ pub fn uncompress_tar(decoder: &mut dyn Read, target: &Path, log: &Logger) -> Re } else { path.to_path_buf() }; - if entry_path.as_os_str().is_empty() { - return Err(anyhow!("Unsafe tar entry (empty path)")); - } check_path_traversal(&entry_path)?; let entry_target = target.join(entry_path); fs::create_dir_all(entry_target.parent().unwrap())?; @@ -841,6 +838,6 @@ mod tests { let log = Logger::new(); let err = uncompress_tar(&mut decoder, &target, &log).unwrap_err(); - assert!(err.to_string().contains("Unsafe tar entry (empty path)")); + assert!(err.to_string().contains("Unsafe entry (path traversal)")); } } From c5d6d7e16c214e44867fd33ff90ce13f8e579651 Mon Sep 17 00:00:00 2001 From: Boni Garcia Date: Thu, 18 Jun 2026 17:07:28 +0200 Subject: [PATCH 3/4] [rust] Simplify unit tests for path traversal logig --- rust/src/files.rs | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/rust/src/files.rs b/rust/src/files.rs index 606f721ef0787..c5dfd26990d52 100644 --- a/rust/src/files.rs +++ b/rust/src/files.rs @@ -761,34 +761,12 @@ mod tests { assert!(check_path_traversal(Path::new("browser/file.txt")).is_ok()); } - #[test] - fn check_path_traversal_rejects_parent_dir() { - let err = check_path_traversal(Path::new("browser/../../escape.txt")).unwrap_err(); - assert!(err.to_string().contains("Unsafe entry (path traversal)")); - } - #[test] fn check_path_traversal_rejects_empty_path() { let err = check_path_traversal(Path::new("")).unwrap_err(); assert!(err.to_string().contains("Unsafe entry (path traversal)")); } - #[test] - fn check_path_traversal_rejects_absolute_path() { - let err = check_path_traversal(Path::new("/tmp/evil")).unwrap_err(); - assert!(err.to_string().contains("Unsafe entry (path traversal)")); - } - - #[cfg(windows)] - #[test] - fn check_path_traversal_rejects_windows_prefixed_paths() { - let err = check_path_traversal(Path::new(r"C:\evil")).unwrap_err(); - assert!(err.to_string().contains("Unsafe entry (path traversal)")); - - let err = check_path_traversal(Path::new(r"\\server\share\evil")).unwrap_err(); - assert!(err.to_string().contains("Unsafe entry (path traversal)")); - } - #[test] fn uncompress_tar_extracts_safe_entry() { let temp_dir = tempfile::tempdir().unwrap(); @@ -829,15 +807,4 @@ mod tests { assert!(!escape_path.exists()); } - #[test] - fn uncompress_tar_rejects_empty_entry_path() { - let temp_dir = tempfile::tempdir().unwrap(); - let target = temp_dir.path().join("extract"); - let tar_data = build_tar(&[("", b"owned")]); - let mut decoder = Cursor::new(tar_data); - let log = Logger::new(); - - let err = uncompress_tar(&mut decoder, &target, &log).unwrap_err(); - assert!(err.to_string().contains("Unsafe entry (path traversal)")); - } } From 6c652bbb8906dfa2b4f41130274fa1930617937d Mon Sep 17 00:00:00 2001 From: Boni Garcia Date: Thu, 18 Jun 2026 17:08:31 +0200 Subject: [PATCH 4/4] [rust] Format code in files module --- rust/src/files.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/rust/src/files.rs b/rust/src/files.rs index c5dfd26990d52..8331aa03a073b 100644 --- a/rust/src/files.rs +++ b/rust/src/files.rs @@ -95,13 +95,13 @@ pub fn create_path_if_not_exists(path: &Path) -> Result<(), Error> { pub fn check_path_traversal(entry_path: &Path) -> Result<(), Error> { if entry_path.as_os_str().is_empty() || entry_path.components().any(|c| { - matches!( - c, - std::path::Component::ParentDir - | std::path::Component::RootDir - | std::path::Component::Prefix(_) - ) - }) + matches!( + c, + std::path::Component::ParentDir + | std::path::Component::RootDir + | std::path::Component::Prefix(_) + ) + }) { return Err(anyhow!("Unsafe entry (path traversal): {:?}", entry_path)); } @@ -806,5 +806,4 @@ mod tests { assert!(err.to_string().contains("Unsafe entry (path traversal)")); assert!(!escape_path.exists()); } - }