diff --git a/crates/ecstore/src/disk/fs.rs b/crates/ecstore/src/disk/fs.rs index 63d3216c2..dd41551c5 100644 --- a/crates/ecstore/src/disk/fs.rs +++ b/crates/ecstore/src/disk/fs.rs @@ -150,11 +150,11 @@ pub fn access_std(path: impl AsRef) -> io::Result<()> { } pub async fn lstat(path: impl AsRef) -> io::Result { - fs::metadata(path).await + fs::symlink_metadata(path).await } pub fn lstat_std(path: impl AsRef) -> io::Result { - std::fs::metadata(path) + std::fs::symlink_metadata(path) } pub async fn make_dir_all(path: impl AsRef) -> io::Result<()> { @@ -355,6 +355,22 @@ mod tests { assert_eq!(metadata.len(), 12); // "test content" is 12 bytes } + #[cfg(unix)] + #[tokio::test] + async fn test_lstat_preserves_symlink_metadata() { + use std::os::unix::fs::symlink; + + let temp_dir = TempDir::new().unwrap(); + let target_path = temp_dir.path().join("target.txt"); + let link_path = temp_dir.path().join("link.txt"); + + tokio::fs::write(&target_path, b"test content").await.unwrap(); + symlink(&target_path, &link_path).unwrap(); + + let metadata = lstat(&link_path).await.unwrap(); + assert!(metadata.file_type().is_symlink()); + } + #[test] fn test_lstat_std() { let temp_dir = TempDir::new().unwrap(); @@ -369,6 +385,22 @@ mod tests { assert_eq!(metadata.len(), 12); // "test content" is 12 bytes } + #[cfg(unix)] + #[test] + fn test_lstat_std_preserves_symlink_metadata() { + use std::os::unix::fs::symlink; + + let temp_dir = TempDir::new().unwrap(); + let target_path = temp_dir.path().join("target-std.txt"); + let link_path = temp_dir.path().join("link-std.txt"); + + std::fs::write(&target_path, b"test content").unwrap(); + symlink(&target_path, &link_path).unwrap(); + + let metadata = lstat_std(&link_path).unwrap(); + assert!(metadata.file_type().is_symlink()); + } + #[tokio::test] async fn test_make_dir_all() { let temp_dir = TempDir::new().unwrap(); diff --git a/crates/ecstore/src/disk/local.rs b/crates/ecstore/src/disk/local.rs index 86bdd6ebd..9a118e426 100644 --- a/crates/ecstore/src/disk/local.rs +++ b/crates/ecstore/src/disk/local.rs @@ -991,11 +991,32 @@ impl LocalDisk { // Check if a path is valid fn check_valid_path>(&self, path: P) -> Result<()> { let path = normalize_path_components(path); - if path.starts_with(&self.root) { - Ok(()) - } else { - Err(DiskError::InvalidPath) + if !path.starts_with(&self.root) { + return Err(DiskError::InvalidPath); } + + self.reject_symlink_components(&path) + } + + fn reject_symlink_components(&self, path: &Path) -> Result<()> { + let relative = path.strip_prefix(&self.root).map_err(|_| DiskError::InvalidPath)?; + let mut current = self.root.clone(); + + for component in relative.components() { + current.push(component.as_os_str()); + + match lstat_std(¤t) { + Ok(metadata) => { + if metadata.file_type().is_symlink() { + return Err(DiskError::InvalidPath); + } + } + Err(err) if err.kind() == ErrorKind::NotFound => break, + Err(err) => return Err(to_file_error(err).into()), + } + } + + Ok(()) } // Batch path generation with single lock acquisition @@ -4456,6 +4477,42 @@ mod test { let _ = fs::remove_dir_all(&test_dir).await; } + #[cfg(unix)] + #[tokio::test] + async fn test_get_bucket_path_rejects_symlink_escape() { + use std::os::unix::fs::symlink; + use tempfile::tempdir; + + let root_dir = tempdir().unwrap(); + let outside_dir = tempdir().unwrap(); + let link_path = root_dir.path().join("escape-bucket"); + symlink(outside_dir.path(), &link_path).unwrap(); + + let endpoint = Endpoint::try_from(root_dir.path().to_string_lossy().as_ref()).unwrap(); + let disk = LocalDisk::new(&endpoint, false).await.unwrap(); + + assert!(matches!(disk.get_bucket_path("escape-bucket"), Err(DiskError::InvalidPath))); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_get_object_path_rejects_symlink_component_escape() { + use std::os::unix::fs::symlink; + use tempfile::tempdir; + + let root_dir = tempdir().unwrap(); + let outside_dir = tempdir().unwrap(); + let bucket_dir = root_dir.path().join("bucket"); + fs::create_dir_all(&bucket_dir).await.unwrap(); + let link_path = bucket_dir.join("escape"); + symlink(outside_dir.path(), &link_path).unwrap(); + + let endpoint = Endpoint::try_from(root_dir.path().to_string_lossy().as_ref()).unwrap(); + let disk = LocalDisk::new(&endpoint, false).await.unwrap(); + + assert!(matches!(disk.get_object_path("bucket", "escape/object.txt"), Err(DiskError::InvalidPath))); + } + #[tokio::test] async fn test_local_disk_file_operations() { let test_dir = "./test_local_disk_file_ops";