codec review

This commit is contained in:
2025-11-12 21:36:13 +01:00
parent d9f8a342e6
commit bdfb461efc
2 changed files with 233 additions and 103 deletions
+126 -79
View File
@@ -1,79 +1,90 @@
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf};
use eyre::WrapErr;
fn canonicalize_base_dir(base_dir: &Path) -> eyre::Result<PathBuf> {
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<PathBuf> {
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<PathBuf> {
// Create the full path by joining base directory with relative path
let full_path = base_dir.join(relative_path);
let canonical_base = canonicalize_base_dir(base_dir)?;
let relative = sanitize_relative_path(relative_path)?;
// Normalize the path to resolve any ".." or "." components
let canonical_path = match std::fs::canonicalize(&full_path) {
Ok(path) => path,
Err(_) => {
// If the file doesn't exist, we can't canonicalize it.
// Instead, we'll manually validate the path components.
validate_path_components(&full_path, base_dir)?
}
};
let mut resolved = canonical_base.clone();
// Get the canonical base directory
let canonical_base = match std::fs::canonicalize(base_dir) {
Ok(path) => path,
Err(_) => {
// If base directory doesn't exist, use its absolute form
match std::fs::canonicalize(base_dir) {
Ok(path) => path,
Err(_) => base_dir.to_path_buf(),
}
}
};
// Ensure the canonical path starts with the canonical base directory
if !canonical_path.starts_with(&canonical_base) {
eyre::bail!(
"Path validation failed: {} attempts to escape base directory",
relative_path
);
}
Ok(canonical_path)
}
/// Validates path components without requiring the file to exist
fn validate_path_components(full_path: &Path, base_dir: &Path) -> eyre::Result<PathBuf> {
let mut result = base_dir.to_path_buf();
for component in full_path.components() {
for component in relative.components() {
match component {
std::path::Component::Prefix(_) => {
eyre::bail!("Path contains Windows prefix: {:?}", full_path);
Component::Prefix(_) | Component::RootDir => {
eyre::bail!("Path is not relative: {relative_path}");
}
std::path::Component::RootDir | std::path::Component::CurDir => {
// Skip root directory and current directory components
Component::ParentDir => {
eyre::bail!("Path attempts to escape base directory: {relative_path}");
}
std::path::Component::ParentDir => {
// Check if we can go up one level without escaping base_dir
if !result.pop() {
eyre::bail!(
"Path attempts to escape base directory with '..': {:?}",
full_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;
}
}
std::path::Component::Normal(name) => {
// Validate the component name
let name_str = name.to_string_lossy();
if name_str.contains('\0') || name_str.contains('/') || name_str.contains('\\') {
eyre::bail!("Path component contains invalid characters: {}", name_str);
}
result.push(name);
}
}
}
Ok(result)
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.
@@ -82,24 +93,6 @@ fn validate_path_components(full_path: &Path, base_dir: &Path) -> eyre::Result<P
/// # 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<PathBuf> {
// First check if the relative path is actually relative
if Path::new(relative_path).is_absolute() {
eyre::bail!("Path must be relative, not absolute: {}", relative_path);
}
// Check for obvious traversal attempts
if relative_path.contains("..") {
eyre::bail!("Path contains directory traversal: {}", relative_path);
}
// For Windows paths
if relative_path.contains(':')
&& (relative_path.contains(":\\") || relative_path.contains(":/"))
{
eyre::bail!("Path contains drive letter: {}", relative_path);
}
// Use the general validation function
validate_relative_path(game_dir, relative_path)
}
@@ -109,7 +102,15 @@ mod tests {
fn create_temp_dir() -> std::io::Result<std::path::PathBuf> {
let mut dir = std::env::temp_dir();
dir.push(format!("lanspread_test_{}", std::process::id()));
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)
}
@@ -143,6 +144,13 @@ mod tests {
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());
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]
@@ -156,6 +164,29 @@ mod tests {
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();
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");
@@ -178,4 +209,20 @@ mod tests {
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());
}
}