botserver/src/security/path_guard.rs
Rodrigo Rodriguez (Pragmatismo) c67aaa677a feat(security): Complete security infrastructure implementation
SECURITY MODULES ADDED:
- security/auth.rs: Full RBAC with roles (Anonymous, User, Moderator, Admin, SuperAdmin, Service, Bot, BotOwner, BotOperator, BotViewer) and permissions
- security/cors.rs: Hardened CORS (no wildcard in production, env-based config)
- security/panic_handler.rs: Panic catching middleware with safe 500 responses
- security/path_guard.rs: Path traversal protection, null byte prevention
- security/request_id.rs: UUID request tracking with correlation IDs
- security/error_sanitizer.rs: Sensitive data redaction from responses
- security/zitadel_auth.rs: Zitadel token introspection and role mapping
- security/sql_guard.rs: SQL injection prevention with table whitelist
- security/command_guard.rs: Command injection prevention
- security/secrets.rs: Zeroizing secret management
- security/validation.rs: Input validation utilities
- security/rate_limiter.rs: Rate limiting with governor crate
- security/headers.rs: Security headers (CSP, HSTS, X-Frame-Options)

MAIN.RS UPDATES:
- Replaced tower_http::cors::Any with hardened create_cors_layer()
- Added panic handler middleware
- Added request ID tracking middleware
- Set global panic hook

SECURITY STATUS:
- 0 unwrap() in production code
- 0 panic! in production code
- 0 unsafe blocks
- cargo audit: PASS (no vulnerabilities)
- Estimated completion: ~98%

Remaining: Wire auth middleware to handlers, audit logs for sensitive data
2025-12-28 19:29:18 -03:00

621 lines
18 KiB
Rust

use std::path::{Component, Path, PathBuf};
use tracing::warn;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathGuardError {
PathTraversal,
AbsolutePath,
InvalidComponent,
EmptyPath,
OutsideAllowedRoot,
SymlinkNotAllowed,
HiddenFileNotAllowed,
InvalidExtension,
PathTooLong,
NullByte,
}
impl std::fmt::Display for PathGuardError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::PathTraversal => write!(f, "Path traversal attempt detected"),
Self::AbsolutePath => write!(f, "Absolute paths are not allowed"),
Self::InvalidComponent => write!(f, "Invalid path component"),
Self::EmptyPath => write!(f, "Empty path is not allowed"),
Self::OutsideAllowedRoot => write!(f, "Path is outside allowed root directory"),
Self::SymlinkNotAllowed => write!(f, "Symbolic links are not allowed"),
Self::HiddenFileNotAllowed => write!(f, "Hidden files are not allowed"),
Self::InvalidExtension => write!(f, "File extension is not allowed"),
Self::PathTooLong => write!(f, "Path exceeds maximum length"),
Self::NullByte => write!(f, "Path contains null byte"),
}
}
}
impl std::error::Error for PathGuardError {}
#[derive(Debug, Clone)]
pub struct PathGuardConfig {
pub allowed_roots: Vec<PathBuf>,
pub allow_symlinks: bool,
pub allow_hidden_files: bool,
pub allowed_extensions: Option<Vec<String>>,
pub denied_extensions: Vec<String>,
pub max_path_length: usize,
pub max_depth: usize,
}
impl Default for PathGuardConfig {
fn default() -> Self {
Self {
allowed_roots: vec![],
allow_symlinks: false,
allow_hidden_files: false,
allowed_extensions: None,
denied_extensions: vec![
"exe".to_string(),
"bat".to_string(),
"cmd".to_string(),
"sh".to_string(),
"ps1".to_string(),
"vbs".to_string(),
"js".to_string(),
"jar".to_string(),
"msi".to_string(),
"dll".to_string(),
"so".to_string(),
],
max_path_length: 4096,
max_depth: 20,
}
}
}
impl PathGuardConfig {
pub fn new() -> Self {
Self::default()
}
pub fn permissive() -> Self {
Self {
allowed_roots: vec![],
allow_symlinks: true,
allow_hidden_files: true,
allowed_extensions: None,
denied_extensions: vec![],
max_path_length: 8192,
max_depth: 50,
}
}
pub fn strict() -> Self {
Self {
allowed_roots: vec![],
allow_symlinks: false,
allow_hidden_files: false,
allowed_extensions: Some(vec![
"txt".to_string(),
"pdf".to_string(),
"doc".to_string(),
"docx".to_string(),
"xls".to_string(),
"xlsx".to_string(),
"csv".to_string(),
"json".to_string(),
"xml".to_string(),
"png".to_string(),
"jpg".to_string(),
"jpeg".to_string(),
"gif".to_string(),
"svg".to_string(),
"mp3".to_string(),
"mp4".to_string(),
"wav".to_string(),
"zip".to_string(),
]),
denied_extensions: vec![],
max_path_length: 2048,
max_depth: 10,
}
}
pub fn with_root(mut self, root: impl Into<PathBuf>) -> Self {
self.allowed_roots.push(root.into());
self
}
pub fn with_roots(mut self, roots: Vec<PathBuf>) -> Self {
self.allowed_roots = roots;
self
}
pub fn allow_symlinks(mut self, allow: bool) -> Self {
self.allow_symlinks = allow;
self
}
pub fn allow_hidden(mut self, allow: bool) -> Self {
self.allow_hidden_files = allow;
self
}
pub fn with_allowed_extensions(mut self, extensions: Vec<String>) -> Self {
self.allowed_extensions = Some(extensions);
self
}
pub fn with_denied_extensions(mut self, extensions: Vec<String>) -> Self {
self.denied_extensions = extensions;
self
}
pub fn with_max_length(mut self, length: usize) -> Self {
self.max_path_length = length;
self
}
pub fn with_max_depth(mut self, depth: usize) -> Self {
self.max_depth = depth;
self
}
}
pub struct PathGuard {
config: PathGuardConfig,
}
impl Default for PathGuard {
fn default() -> Self {
Self::new(PathGuardConfig::default())
}
}
impl PathGuard {
pub fn new(config: PathGuardConfig) -> Self {
Self { config }
}
pub fn validate(&self, path: &Path) -> Result<PathBuf, PathGuardError> {
let path_str = path.to_string_lossy();
if path_str.contains('\0') {
warn!(path = %path_str, "Path contains null byte");
return Err(PathGuardError::NullByte);
}
if path_str.is_empty() {
return Err(PathGuardError::EmptyPath);
}
if path_str.len() > self.config.max_path_length {
warn!(path_len = path_str.len(), max = self.config.max_path_length, "Path too long");
return Err(PathGuardError::PathTooLong);
}
if path.is_absolute() && !self.config.allowed_roots.is_empty() {
let is_within_root = self.config.allowed_roots.iter().any(|root| {
path.starts_with(root)
});
if !is_within_root {
warn!(path = %path_str, "Absolute path outside allowed roots");
return Err(PathGuardError::AbsolutePath);
}
}
let mut depth: usize = 0;
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::ParentDir => {
if normalized.pop() {
depth = depth.saturating_sub(1);
} else {
warn!(path = %path_str, "Path traversal attempt detected");
return Err(PathGuardError::PathTraversal);
}
}
Component::Normal(name) => {
let name_str = name.to_string_lossy();
if !self.config.allow_hidden_files && name_str.starts_with('.') {
warn!(path = %path_str, component = %name_str, "Hidden file not allowed");
return Err(PathGuardError::HiddenFileNotAllowed);
}
if has_dangerous_patterns(&name_str) {
warn!(path = %path_str, component = %name_str, "Invalid path component");
return Err(PathGuardError::InvalidComponent);
}
normalized.push(name);
depth += 1;
if depth > self.config.max_depth {
warn!(path = %path_str, depth = depth, max = self.config.max_depth, "Path depth exceeded");
return Err(PathGuardError::PathTooLong);
}
}
Component::RootDir => {
normalized.push(Component::RootDir);
}
Component::Prefix(prefix) => {
normalized.push(prefix.as_os_str());
}
Component::CurDir => {}
}
}
if let Some(ext) = normalized.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
if let Some(ref allowed) = self.config.allowed_extensions {
if !allowed.iter().any(|e| e.to_lowercase() == ext_str) {
warn!(path = %path_str, extension = %ext_str, "Extension not in allowed list");
return Err(PathGuardError::InvalidExtension);
}
}
if self.config.denied_extensions.iter().any(|e| e.to_lowercase() == ext_str) {
warn!(path = %path_str, extension = %ext_str, "Extension is denied");
return Err(PathGuardError::InvalidExtension);
}
}
Ok(normalized)
}
pub fn validate_and_resolve(&self, base: &Path, path: &Path) -> Result<PathBuf, PathGuardError> {
let validated = self.validate(path)?;
let full_path = base.join(&validated);
if !self.config.allowed_roots.is_empty() {
let is_within_root = self.config.allowed_roots.iter().any(|root| {
full_path.starts_with(root)
});
if !is_within_root {
warn!(
path = %full_path.display(),
"Resolved path outside allowed roots"
);
return Err(PathGuardError::OutsideAllowedRoot);
}
}
Ok(full_path)
}
pub fn validate_existing(&self, path: &Path) -> Result<PathBuf, PathGuardError> {
let validated = self.validate(path)?;
if !self.config.allow_symlinks && validated.is_symlink() {
warn!(path = %validated.display(), "Symlink not allowed");
return Err(PathGuardError::SymlinkNotAllowed);
}
if let Ok(canonical) = validated.canonicalize() {
if !self.config.allowed_roots.is_empty() {
let is_within_root = self.config.allowed_roots.iter().any(|root| {
if let Ok(root_canonical) = root.canonicalize() {
canonical.starts_with(&root_canonical)
} else {
canonical.starts_with(root)
}
});
if !is_within_root {
warn!(
path = %canonical.display(),
"Canonical path outside allowed roots"
);
return Err(PathGuardError::OutsideAllowedRoot);
}
}
Ok(canonical)
} else {
Ok(validated)
}
}
}
fn has_dangerous_patterns(name: &str) -> bool {
let dangerous = [
"..",
"...",
"~",
"$",
"`",
"|",
";",
"&",
"<",
">",
"\\",
"%00",
"%2e",
"%2f",
"%5c",
"\r",
"\n",
"\t",
];
for pattern in &dangerous {
if name.contains(pattern) {
return true;
}
}
if name.chars().any(|c| c.is_control()) {
return true;
}
false
}
pub fn sanitize_filename(name: &str) -> String {
let dangerous_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|', '\0'];
let sanitized: String = name
.chars()
.map(|c| {
if dangerous_chars.contains(&c) || c.is_control() {
'_'
} else {
c
}
})
.collect();
let sanitized = sanitized.trim_matches(|c| c == '.' || c == ' ');
if sanitized.is_empty() {
return "unnamed".to_string();
}
let reserved = [
"CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
let upper = sanitized.to_uppercase();
let base_name = upper.split('.').next().unwrap_or("");
if reserved.contains(&base_name) {
return format!("_{}", sanitized);
}
if sanitized.len() > 255 {
sanitized[..255].to_string()
} else {
sanitized.to_string()
}
}
pub fn sanitize_path_component(component: &str) -> String {
sanitize_filename(component)
}
pub fn is_safe_path(path: &Path) -> bool {
PathGuard::default().validate(path).is_ok()
}
pub fn join_safe(base: &Path, relative: &Path) -> Result<PathBuf, PathGuardError> {
let guard = PathGuard::new(PathGuardConfig::default().with_root(base.to_path_buf()));
guard.validate_and_resolve(base, relative)
}
pub fn canonicalize_safe(path: &Path, allowed_root: &Path) -> Result<PathBuf, PathGuardError> {
let guard = PathGuard::new(PathGuardConfig::default().with_root(allowed_root.to_path_buf()));
guard.validate_existing(path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_path() {
let guard = PathGuard::default();
assert!(guard.validate(Path::new("foo/bar/file.txt")).is_ok());
}
#[test]
fn test_path_traversal_simple() {
let guard = PathGuard::default();
assert_eq!(
guard.validate(Path::new("../secret")).unwrap_err(),
PathGuardError::PathTraversal
);
}
#[test]
fn test_path_traversal_embedded() {
let guard = PathGuard::default();
assert_eq!(
guard.validate(Path::new("foo/../../secret")).unwrap_err(),
PathGuardError::PathTraversal
);
}
#[test]
fn test_valid_parent_traversal() {
let guard = PathGuard::default();
let result = guard.validate(Path::new("foo/bar/../baz/file.txt"));
assert!(result.is_ok());
assert_eq!(result.unwrap(), PathBuf::from("foo/baz/file.txt"));
}
#[test]
fn test_hidden_file_blocked() {
let guard = PathGuard::default();
assert_eq!(
guard.validate(Path::new("foo/.secret")).unwrap_err(),
PathGuardError::HiddenFileNotAllowed
);
}
#[test]
fn test_hidden_file_allowed() {
let guard = PathGuard::new(PathGuardConfig::default().allow_hidden(true));
assert!(guard.validate(Path::new("foo/.gitignore")).is_ok());
}
#[test]
fn test_denied_extension() {
let guard = PathGuard::default();
assert_eq!(
guard.validate(Path::new("script.exe")).unwrap_err(),
PathGuardError::InvalidExtension
);
}
#[test]
fn test_allowed_extension() {
let guard = PathGuard::new(
PathGuardConfig::default().with_allowed_extensions(vec!["txt".to_string()])
);
assert!(guard.validate(Path::new("file.txt")).is_ok());
assert_eq!(
guard.validate(Path::new("file.pdf")).unwrap_err(),
PathGuardError::InvalidExtension
);
}
#[test]
fn test_empty_path() {
let guard = PathGuard::default();
assert_eq!(
guard.validate(Path::new("")).unwrap_err(),
PathGuardError::EmptyPath
);
}
#[test]
fn test_max_depth() {
let guard = PathGuard::new(PathGuardConfig::default().with_max_depth(3));
assert!(guard.validate(Path::new("a/b/c")).is_ok());
assert_eq!(
guard.validate(Path::new("a/b/c/d")).unwrap_err(),
PathGuardError::PathTooLong
);
}
#[test]
fn test_max_length() {
let guard = PathGuard::new(PathGuardConfig::default().with_max_length(10));
assert!(guard.validate(Path::new("short.txt")).is_ok());
assert_eq!(
guard.validate(Path::new("very_long_filename.txt")).unwrap_err(),
PathGuardError::PathTooLong
);
}
#[test]
fn test_sanitize_filename() {
assert_eq!(sanitize_filename("normal.txt"), "normal.txt");
assert_eq!(sanitize_filename("file/with\\slashes"), "file_with_slashes");
assert_eq!(sanitize_filename("file:name"), "file_name");
assert_eq!(sanitize_filename("..."), "unnamed");
assert_eq!(sanitize_filename(" "), "unnamed");
assert_eq!(sanitize_filename("CON"), "_CON");
assert_eq!(sanitize_filename("CON.txt"), "_CON.txt");
}
#[test]
fn test_sanitize_filename_long() {
let long_name = "a".repeat(300);
let sanitized = sanitize_filename(&long_name);
assert_eq!(sanitized.len(), 255);
}
#[test]
fn test_dangerous_patterns() {
assert!(has_dangerous_patterns(".."));
assert!(has_dangerous_patterns("file%2f"));
assert!(has_dangerous_patterns("file;cmd"));
assert!(has_dangerous_patterns("file`inject`"));
assert!(!has_dangerous_patterns("normal_file.txt"));
}
#[test]
fn test_is_safe_path() {
assert!(is_safe_path(Path::new("documents/file.txt")));
assert!(!is_safe_path(Path::new("../secret")));
}
#[test]
fn test_join_safe() {
let base = Path::new("/data/uploads");
assert!(join_safe(base, Path::new("user/file.txt")).is_ok());
}
#[test]
fn test_config_builder() {
let config = PathGuardConfig::new()
.with_root("/data")
.allow_hidden(true)
.allow_symlinks(true)
.with_max_depth(5)
.with_max_length(1000);
assert_eq!(config.allowed_roots.len(), 1);
assert!(config.allow_hidden_files);
assert!(config.allow_symlinks);
assert_eq!(config.max_depth, 5);
assert_eq!(config.max_path_length, 1000);
}
#[test]
fn test_strict_config() {
let config = PathGuardConfig::strict();
assert!(!config.allow_symlinks);
assert!(!config.allow_hidden_files);
assert!(config.allowed_extensions.is_some());
}
#[test]
fn test_permissive_config() {
let config = PathGuardConfig::permissive();
assert!(config.allow_symlinks);
assert!(config.allow_hidden_files);
assert!(config.denied_extensions.is_empty());
}
#[test]
fn test_null_byte() {
let guard = PathGuard::default();
let path = Path::new("file\0.txt");
assert_eq!(
guard.validate(path).unwrap_err(),
PathGuardError::NullByte
);
}
#[test]
fn test_path_guard_error_display() {
assert_eq!(
PathGuardError::PathTraversal.to_string(),
"Path traversal attempt detected"
);
assert_eq!(
PathGuardError::EmptyPath.to_string(),
"Empty path is not allowed"
);
}
#[test]
fn test_current_dir_component() {
let guard = PathGuard::default();
let result = guard.validate(Path::new("foo/./bar/./file.txt"));
assert!(result.is_ok());
assert_eq!(result.unwrap(), PathBuf::from("foo/bar/file.txt"));
}
#[test]
fn test_case_insensitive_extension() {
let guard = PathGuard::default();
assert_eq!(
guard.validate(Path::new("script.EXE")).unwrap_err(),
PathGuardError::InvalidExtension
);
}
}