use std::path::{Component, Path, PathBuf}; use eyre::WrapErr; fn canonicalize_base_dir(base_dir: &Path) -> eyre::Result { if !base_dir.is_absolute() { eyre::bail!("Base directory must be absolute: {}", base_dir.display()); } let canonical = std::fs::canonicalize(base_dir).unwrap_or_else(|_| base_dir.to_path_buf()); Ok(canonical) } fn sanitize_relative_path(relative_path: &str) -> eyre::Result { if relative_path.is_empty() { eyre::bail!("Relative path cannot be empty"); } if relative_path.contains('\0') { eyre::bail!("Path contains null byte"); } // Normalise Windows separators so that component checks work uniformly. let normalised = relative_path.replace('\\', "/"); if normalised.starts_with("//") { eyre::bail!("UNC paths are not allowed: {relative_path}"); } if normalised.contains(":/") { eyre::bail!("Path contains drive letter: {relative_path}"); } let path = PathBuf::from(&normalised); if path.is_absolute() { eyre::bail!("Path must be relative, not absolute: {relative_path}"); } Ok(path) } /// Validates and sanitizes a relative path to prevent directory traversal attacks. /// /// # Errors /// Returns an error if the path attempts to escape the base directory or contains invalid components. pub fn validate_relative_path(base_dir: &Path, relative_path: &str) -> eyre::Result { let canonical_base = canonicalize_base_dir(base_dir)?; let relative = sanitize_relative_path(relative_path)?; let mut resolved = canonical_base.clone(); for component in relative.components() { match component { Component::Prefix(_) | Component::RootDir => { eyre::bail!("Path is not relative: {relative_path}"); } Component::ParentDir => { eyre::bail!("Path attempts to escape base directory: {relative_path}"); } Component::CurDir => {} Component::Normal(part) => { resolved.push(part); if let Ok(metadata) = std::fs::symlink_metadata(&resolved) && metadata.file_type().is_symlink() { let target = std::fs::canonicalize(&resolved).wrap_err_with(|| { format!("Failed to canonicalize symlink {}", resolved.display()) })?; if !target.starts_with(&canonical_base) { eyre::bail!( "Path validation failed: {relative_path} escapes the base directory" ); } resolved = target; } } } } if !resolved.starts_with(&canonical_base) { eyre::bail!("Path validation failed: {relative_path} escapes the base directory"); } Ok(resolved) } /// Validates a relative path that will be used for accessing files within a game directory. /// This is a stricter validation that ensures the path is relative and doesn't escape. /// /// # Errors /// Returns an error if the path is absolute or attempts directory traversal. pub fn validate_game_file_path(game_dir: &Path, relative_path: &str) -> eyre::Result { validate_relative_path(game_dir, relative_path) } #[cfg(test)] mod tests { use super::*; fn create_temp_dir() -> std::io::Result { let mut dir = std::env::temp_dir(); let unique = format!( "lanspread_test_{}_{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_nanos() ); dir.push(unique); std::fs::create_dir_all(&dir)?; Ok(dir) } struct TempDir(std::path::PathBuf); impl TempDir { fn new() -> std::io::Result { let path = create_temp_dir()?; Ok(TempDir(path)) } fn path(&self) -> &std::path::Path { &self.0 } } impl Drop for TempDir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } #[test] fn test_valid_paths() { let temp_dir = TempDir::new().expect("Failed to create temp dir for test"); let base = temp_dir.path(); // Valid relative paths assert!(validate_game_file_path(base, "file.txt").is_ok()); assert!(validate_game_file_path(base, "subdir/file.txt").is_ok()); assert!(validate_game_file_path(base, "subdir/deep/nested/file.txt").is_ok()); assert!(validate_game_file_path(base, "file with spaces.txt").is_ok()); assert!(validate_game_file_path(base, "./dot/./path.txt").is_ok()); #[allow(clippy::unwrap_used)] let windows_style = validate_game_file_path(base, "mix\\windows\\path.txt").unwrap(); assert_eq!( windows_style, base.join("mix").join("windows").join("path.txt") ); } #[test] fn test_traversal_attempts() { let temp_dir = TempDir::new().expect("Failed to create temp dir for test"); let base = temp_dir.path(); // These should all fail assert!(validate_game_file_path(base, "../outside.txt").is_err()); assert!(validate_game_file_path(base, "subdir/../../outside.txt").is_err()); assert!(validate_game_file_path(base, "../../etc/passwd").is_err()); } #[test] fn test_double_dot_in_filename_allowed() { let temp_dir = TempDir::new().expect("Failed to create temp dir for test"); let base = temp_dir.path(); assert!(validate_game_file_path(base, "data/file..txt").is_ok()); } #[test] fn test_missing_file_stays_within_base() { let temp_dir = TempDir::new().expect("Failed to create temp dir for test"); let base = temp_dir.path(); #[allow(clippy::unwrap_used)] let resolved = validate_game_file_path(base, "new_dir/new_file.bin").unwrap(); assert_eq!(resolved, base.join("new_dir").join("new_file.bin")); } #[test] fn test_relative_base_dir_rejected() { let relative_base = std::path::Path::new("relative/base"); assert!(validate_game_file_path(relative_base, "file.bin").is_err()); } #[test] fn test_absolute_paths() { let temp_dir = TempDir::new().expect("Failed to create temp dir for test"); let base = temp_dir.path(); // Absolute paths should fail assert!(validate_game_file_path(base, "/etc/passwd").is_err()); if cfg!(windows) { assert!(validate_game_file_path(base, "C:\\Windows\\System32\\cmd.exe").is_err()); } } #[test] fn test_windows_specific() { let temp_dir = TempDir::new().expect("Failed to create temp dir for test"); let base = temp_dir.path(); // Windows-specific paths that should fail assert!(validate_game_file_path(base, "C:\\evil.txt").is_err()); assert!(validate_game_file_path(base, "D:/evil.txt").is_err()); assert!(validate_game_file_path(base, "\\\\server\\share\\file.txt").is_err()); } #[cfg(unix)] #[test] fn test_symlink_escape_rejected() { use std::os::unix::fs::symlink; let base_dir = TempDir::new().expect("Failed to create base temp dir"); let outside_dir = TempDir::new().expect("Failed to create outside temp dir"); let base = base_dir.path(); let outside = outside_dir.path(); symlink(outside, base.join("link")).expect("Failed to create symlink"); assert!(validate_game_file_path(base, "link/escape.txt").is_err()); } }