pinakes/crates/pinakes-core/src/path_validation.rs
NotAShelf 3ccddce7fd
treewide: fix various UI bugs; optimize crypto dependencies & format
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: If8fe8b38c1d9c4fecd40ff71f88d2ae06a6a6964
2026-03-06 18:29:33 +03:00

323 lines
8.9 KiB
Rust

//! Path validation utilities to prevent path traversal attacks.
//!
//! This module provides functions to validate and sanitize file paths,
//! ensuring they remain within allowed root directories and don't contain
//! malicious path traversal sequences.
use std::path::{Path, PathBuf};
use crate::error::{PinakesError, Result};
/// Validates that a path is within one of the allowed root directories.
///
/// This function:
/// 1. Canonicalizes the path to resolve any symlinks and `..` sequences
/// 2. Checks that the canonical path starts with one of the allowed roots
/// 3. Returns the canonical path if valid, or an error if not
///
/// # Security
///
/// This prevents path traversal attacks where an attacker might try to
/// access files outside the allowed directories using sequences like:
/// - `../../../etc/passwd`
/// - `/media/../../../etc/passwd`
/// - Symlinks pointing outside allowed roots
///
/// # Arguments
///
/// * `path` - The path to validate
/// * `allowed_roots` - List of allowed root directories
///
/// # Returns
///
/// The canonicalized path if valid, or a `PathNotAllowed` error if the path
/// is outside all allowed roots.
///
/// # Example
///
/// ```no_run
/// use std::path::PathBuf;
///
/// use pinakes_core::path_validation::validate_path;
///
/// let allowed_roots = vec![
/// PathBuf::from("/media"),
/// PathBuf::from("/home/user/documents"),
/// ];
/// let path = PathBuf::from("/media/music/song.mp3");
///
/// let validated = validate_path(&path, &allowed_roots).unwrap();
/// ```
pub fn validate_path(
path: &Path,
allowed_roots: &[PathBuf],
) -> Result<PathBuf> {
// Handle the case where no roots are configured
if allowed_roots.is_empty() {
return Err(PinakesError::PathNotAllowed(
"no allowed roots configured".to_string(),
));
}
// First check if the path exists
if !path.exists() {
return Err(PinakesError::PathNotAllowed(format!(
"path does not exist: {}",
path.display()
)));
}
// Canonicalize to resolve symlinks and relative components
let canonical = path.canonicalize().map_err(|e| {
PinakesError::PathNotAllowed(format!(
"failed to canonicalize path {}: {}",
path.display(),
e
))
})?;
// Check if the canonical path is within any allowed root
let canonical_roots: Vec<PathBuf> = allowed_roots
.iter()
.filter_map(|root| root.canonicalize().ok())
.collect();
if canonical_roots.is_empty() {
return Err(PinakesError::PathNotAllowed(
"no accessible allowed roots".to_string(),
));
}
let is_allowed = canonical_roots
.iter()
.any(|root| canonical.starts_with(root));
if is_allowed {
Ok(canonical)
} else {
Err(PinakesError::PathNotAllowed(format!(
"path {} is outside allowed roots",
path.display()
)))
}
}
/// Validates a path relative to a single root directory.
///
/// This is a convenience wrapper for `validate_path` when you only have one
/// root.
pub fn validate_path_single_root(path: &Path, root: &Path) -> Result<PathBuf> {
validate_path(path, &[root.to_path_buf()])
}
/// Checks if a path appears to contain traversal sequences without
/// canonicalizing.
///
/// This is a quick pre-check that can reject obviously malicious paths without
/// hitting the filesystem. It should be used in addition to `validate_path`,
/// not as a replacement.
///
/// # Arguments
///
/// * `path` - The path string to check
///
/// # Returns
///
/// `true` if the path appears safe (no obvious traversal sequences),
/// `false` if it contains suspicious patterns.
pub fn path_looks_safe(path: &str) -> bool {
// Reject paths with obvious traversal patterns
!path.contains("..")
&& !path.contains("//")
&& !path.starts_with('/')
&& path.chars().filter(|c| *c == '/').count() < 50 // Reasonable depth limit
}
/// Sanitizes a filename by removing or replacing dangerous characters.
///
/// This removes:
/// - Path separators (`/`, `\`)
/// - Null bytes
/// - Control characters
/// - Leading dots (to prevent hidden files)
///
/// # Arguments
///
/// * `filename` - The filename to sanitize
///
/// # Returns
///
/// A sanitized filename safe for use on most filesystems.
pub fn sanitize_filename(filename: &str) -> String {
let sanitized: String = filename
.chars()
.filter(|c| {
// Allow alphanumeric, common punctuation, and unicode letters
c.is_alphanumeric()
|| matches!(*c, '-' | '_' | '.' | ' ' | '(' | ')' | '[' | ']')
})
.collect();
// Remove leading dots to prevent hidden files
let sanitized = sanitized.trim_start_matches('.');
// Remove leading/trailing whitespace
let sanitized = sanitized.trim();
// Ensure the filename isn't empty after sanitization
if sanitized.is_empty() {
"unnamed".to_string()
} else {
sanitized.to_string()
}
}
/// Joins a base path with a relative path safely.
///
/// This ensures the resulting path doesn't escape the base directory
/// through use of `..` or absolute paths in the relative component.
///
/// # Arguments
///
/// * `base` - The base directory
/// * `relative` - The relative path to join
///
/// # Returns
///
/// The joined path if safe, or an error if the relative path would escape the
/// base.
pub fn safe_join(base: &Path, relative: &str) -> Result<PathBuf> {
// Reject absolute paths in the relative component
if relative.starts_with('/') || relative.starts_with('\\') {
return Err(PinakesError::PathNotAllowed(
"relative path cannot be absolute".to_string(),
));
}
// Reject paths with .. traversal
if relative.contains("..") {
return Err(PinakesError::PathNotAllowed(
"relative path cannot contain '..'".to_string(),
));
}
// Build the path and validate it stays within base
let joined = base.join(relative);
// Canonicalize base for comparison
let canonical_base = base.canonicalize().map_err(|e| {
PinakesError::PathNotAllowed(format!(
"failed to canonicalize base {}: {}",
base.display(),
e
))
})?;
// The joined path might not exist yet, so we can't canonicalize it directly.
// Instead, we check each component
let mut current = canonical_base.clone();
for component in Path::new(relative).components() {
use std::path::Component;
match component {
Component::Normal(name) => {
current = current.join(name);
},
Component::ParentDir => {
return Err(PinakesError::PathNotAllowed(
"path traversal detected".to_string(),
));
},
Component::CurDir => continue,
_ => {
return Err(PinakesError::PathNotAllowed(
"invalid path component".to_string(),
));
},
}
}
Ok(joined)
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::TempDir;
use super::*;
fn setup_test_dirs() -> TempDir {
let temp = TempDir::new().unwrap();
fs::create_dir_all(temp.path().join("allowed")).unwrap();
fs::create_dir_all(temp.path().join("forbidden")).unwrap();
fs::write(temp.path().join("allowed/file.txt"), "test").unwrap();
fs::write(temp.path().join("forbidden/secret.txt"), "secret").unwrap();
temp
}
#[test]
fn test_validate_path_allowed() {
let temp = setup_test_dirs();
let allowed_roots = vec![temp.path().join("allowed")];
let path = temp.path().join("allowed/file.txt");
let result = validate_path(&path, &allowed_roots);
assert!(result.is_ok());
}
#[test]
fn test_validate_path_forbidden() {
let temp = setup_test_dirs();
let allowed_roots = vec![temp.path().join("allowed")];
let path = temp.path().join("forbidden/secret.txt");
let result = validate_path(&path, &allowed_roots);
assert!(result.is_err());
}
#[test]
fn test_validate_path_traversal() {
let temp = setup_test_dirs();
let allowed_roots = vec![temp.path().join("allowed")];
let path = temp.path().join("allowed/../forbidden/secret.txt");
let result = validate_path(&path, &allowed_roots);
assert!(result.is_err());
}
#[test]
fn test_sanitize_filename() {
assert_eq!(sanitize_filename("normal.txt"), "normal.txt");
assert_eq!(sanitize_filename("../../../etc/passwd"), "etcpasswd");
assert_eq!(sanitize_filename(".hidden"), "hidden");
assert_eq!(sanitize_filename("file<with>bad:chars"), "filewithbadchars");
assert_eq!(sanitize_filename(""), "unnamed");
assert_eq!(sanitize_filename("..."), "unnamed");
}
#[test]
fn test_path_looks_safe() {
assert!(path_looks_safe("normal/path/file.txt"));
assert!(!path_looks_safe("../../../etc/passwd"));
assert!(!path_looks_safe("path//double/slash"));
}
#[test]
fn test_safe_join() {
let temp = TempDir::new().unwrap();
let base = temp.path();
// Valid join
let result = safe_join(base, "subdir/file.txt");
assert!(result.is_ok());
// Traversal attempt
let result = safe_join(base, "../etc/passwd");
assert!(result.is_err());
// Absolute path attempt
let result = safe_join(base, "/etc/passwd");
assert!(result.is_err());
}
}