Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: If8fe8b38c1d9c4fecd40ff71f88d2ae06a6a6964
323 lines
8.9 KiB
Rust
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());
|
|
}
|
|
}
|