mirror of
https://github.com/allaunthefox/Research-Stack.git
synced 2026-07-31 03:05:21 +00:00
453 lines
14 KiB
Python
453 lines
14 KiB
Python
"""Security Module.
|
|
|
|
Path sanitization and security validation using standard library only.
|
|
|
|
Key Features:
|
|
- Path sanitization (prevent directory traversal)
|
|
- Path validation and normalization
|
|
- Safe filename generation
|
|
- Permission checking
|
|
- Symlink detection
|
|
- Standard library only (no external dependencies)
|
|
|
|
Dependencies:
|
|
- pathlib (standard library)
|
|
- os (standard library)
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Optional, List
|
|
|
|
|
|
class SecurityError(Exception):
|
|
"""Security validation error"""
|
|
|
|
|
|
class Security:
|
|
"""Handle security operations.
|
|
|
|
Provides path sanitization, validation, and security checks
|
|
to prevent common vulnerabilities like path traversal attacks.
|
|
"""
|
|
|
|
# Dangerous path components that should be rejected
|
|
DANGEROUS_PATTERNS = [
|
|
'..', # Parent directory traversal
|
|
'~', # Home directory expansion
|
|
'///', # Multiple slashes
|
|
'\x00', # Null byte
|
|
'\r', # Carriage return
|
|
'\n', # Line feed
|
|
]
|
|
|
|
# Characters not allowed in filenames (Windows + Unix)
|
|
INVALID_FILENAME_CHARS = r'[<>:"|?*\x00-\x1f]'
|
|
|
|
# Reserved Windows filenames
|
|
RESERVED_NAMES = {
|
|
'CON', 'PRN', 'AUX', 'NUL',
|
|
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
|
|
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
|
|
}
|
|
|
|
@staticmethod
|
|
def sanitize_path(
|
|
path: str,
|
|
allow_absolute: bool = True,
|
|
allow_parent: bool = False
|
|
) -> str:
|
|
"""Sanitize a file path to prevent directory traversal attacks.
|
|
|
|
Args:
|
|
path: Path to sanitize
|
|
allow_absolute: Allow absolute paths
|
|
allow_parent: Allow parent directory references (..)
|
|
|
|
Returns:
|
|
Sanitized path string
|
|
|
|
Raises:
|
|
SecurityError: If path contains dangerous patterns
|
|
"""
|
|
try:
|
|
# Convert to string if Path object
|
|
if isinstance(path, Path):
|
|
path = str(path)
|
|
|
|
# Check for null bytes
|
|
if '\x00' in path:
|
|
raise SecurityError("Path contains null bytes")
|
|
|
|
# Check for dangerous patterns
|
|
if not allow_parent:
|
|
if '..' in path:
|
|
raise SecurityError("Path contains parent directory reference (..)")
|
|
|
|
# Normalize path separators
|
|
path = path.replace('\\', '/')
|
|
|
|
# Remove multiple consecutive slashes
|
|
while '//' in path:
|
|
path = path.replace('//', '/')
|
|
|
|
# Convert to Path for normalization
|
|
path_obj = Path(path)
|
|
|
|
# Check if absolute path is allowed
|
|
if path_obj.is_absolute() and not allow_absolute:
|
|
raise SecurityError("Absolute paths not allowed")
|
|
|
|
# Normalize the path
|
|
try:
|
|
normalized = path_obj.resolve()
|
|
except (OSError, RuntimeError):
|
|
# If resolve fails, use manual normalization
|
|
normalized = Path(os.path.normpath(str(path_obj)))
|
|
|
|
return str(normalized)
|
|
|
|
except Exception as e:
|
|
if isinstance(e, SecurityError):
|
|
raise
|
|
raise SecurityError(f"Path sanitization failed: {e}") from e
|
|
|
|
@staticmethod
|
|
def validate_path(
|
|
path: str,
|
|
must_exist: bool = False,
|
|
must_be_file: bool = False,
|
|
must_be_dir: bool = False,
|
|
allowed_parent: Optional[Path] = None
|
|
) -> bool:
|
|
"""Validate a file path for security and existence.
|
|
|
|
Args:
|
|
path: Path to validate
|
|
must_exist: If True, path must exist
|
|
must_be_file: If True, path must be a file
|
|
must_be_dir: If True, path must be a directory
|
|
allowed_parent: If set, path must be within this directory
|
|
|
|
Returns:
|
|
True if path is valid
|
|
|
|
Raises:
|
|
SecurityError: If path is invalid or insecure
|
|
"""
|
|
try:
|
|
# Convert to Path object
|
|
if isinstance(path, str):
|
|
path_obj = Path(path)
|
|
else:
|
|
path_obj = path
|
|
|
|
# Resolve to absolute path
|
|
try:
|
|
resolved = path_obj.resolve()
|
|
except (OSError, RuntimeError) as e:
|
|
raise SecurityError(f"Cannot resolve path: {e}") from e
|
|
|
|
# Check if path must be within allowed parent
|
|
if allowed_parent is not None:
|
|
if isinstance(allowed_parent, str):
|
|
allowed_parent = Path(allowed_parent)
|
|
|
|
try:
|
|
allowed_resolved = allowed_parent.resolve()
|
|
# Check if path is relative to allowed parent
|
|
try:
|
|
resolved.relative_to(allowed_resolved)
|
|
except ValueError:
|
|
raise SecurityError(
|
|
f"Path {resolved} is outside allowed directory {allowed_resolved}"
|
|
)
|
|
except (OSError, RuntimeError) as e:
|
|
raise SecurityError(f"Cannot resolve allowed parent: {e}") from e
|
|
|
|
# Check existence
|
|
if must_exist and not resolved.exists():
|
|
raise SecurityError(f"Path does not exist: {resolved}")
|
|
|
|
# Check if file
|
|
if must_be_file:
|
|
if not resolved.exists():
|
|
raise SecurityError(f"File does not exist: {resolved}")
|
|
if not resolved.is_file():
|
|
raise SecurityError(f"Path is not a file: {resolved}")
|
|
|
|
# Check if directory
|
|
if must_be_dir:
|
|
if not resolved.exists():
|
|
raise SecurityError(f"Directory does not exist: {resolved}")
|
|
if not resolved.is_dir():
|
|
raise SecurityError(f"Path is not a directory: {resolved}")
|
|
|
|
return True
|
|
|
|
except SecurityError:
|
|
raise
|
|
except Exception as e:
|
|
raise SecurityError(f"Path validation failed: {e}") from e
|
|
|
|
@staticmethod
|
|
def sanitize_filename(
|
|
filename: str,
|
|
replacement: str = '_',
|
|
max_length: int = 255
|
|
) -> str:
|
|
"""Sanitize a filename to be safe across platforms.
|
|
|
|
Args:
|
|
filename: Filename to sanitize
|
|
replacement: Character to replace invalid characters with
|
|
max_length: Maximum filename length
|
|
|
|
Returns:
|
|
Sanitized filename
|
|
|
|
Raises:
|
|
SecurityError: If filename cannot be sanitized
|
|
"""
|
|
try:
|
|
# Remove path separators
|
|
filename = os.path.basename(filename)
|
|
|
|
# Check for empty filename
|
|
if not filename or filename in ('.', '..'):
|
|
raise SecurityError("Invalid filename")
|
|
|
|
# Replace invalid characters
|
|
filename = re.sub(Security.INVALID_FILENAME_CHARS, replacement, filename)
|
|
|
|
# Remove leading/trailing spaces and dots
|
|
filename = filename.strip('. ')
|
|
|
|
# Check for reserved names (Windows)
|
|
name_without_ext = filename.split('.')[0].upper()
|
|
if name_without_ext in Security.RESERVED_NAMES:
|
|
filename = f"{replacement}{filename}"
|
|
|
|
# Truncate to max length
|
|
if len(filename) > max_length:
|
|
# Try to preserve extension
|
|
parts = filename.rsplit('.', 1)
|
|
if len(parts) == 2:
|
|
name, ext = parts
|
|
max_name_length = max_length - len(ext) - 1
|
|
filename = f"{name[:max_name_length]}.{ext}"
|
|
else:
|
|
filename = filename[:max_length]
|
|
|
|
# Final check
|
|
if not filename:
|
|
raise SecurityError("Filename became empty after sanitization")
|
|
|
|
return filename
|
|
|
|
except Exception as e:
|
|
if isinstance(e, SecurityError):
|
|
raise
|
|
raise SecurityError(f"Filename sanitization failed: {e}") from e
|
|
|
|
@staticmethod
|
|
def is_safe_path(path: str, base_directory: str) -> bool:
|
|
"""Check if a path is safe (within base directory).
|
|
|
|
Args:
|
|
path: Path to check
|
|
base_directory: Base directory that path must be within
|
|
|
|
Returns:
|
|
True if path is safe
|
|
"""
|
|
try:
|
|
# Convert to Path objects
|
|
path_obj = Path(path).resolve()
|
|
base_obj = Path(base_directory).resolve()
|
|
|
|
# Check if path is relative to base
|
|
try:
|
|
path_obj.relative_to(base_obj)
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
except (OSError, RuntimeError):
|
|
return False
|
|
|
|
@staticmethod
|
|
def check_permissions(
|
|
path: str,
|
|
readable: bool = False,
|
|
writable: bool = False,
|
|
executable: bool = False
|
|
) -> bool:
|
|
"""Check file permissions.
|
|
|
|
Args:
|
|
path: Path to check
|
|
readable: Check if readable
|
|
writable: Check if writable
|
|
executable: Check if executable
|
|
|
|
Returns:
|
|
True if all requested permissions are available
|
|
|
|
Raises:
|
|
SecurityError: If path doesn't exist or permissions insufficient
|
|
"""
|
|
try:
|
|
# Convert to Path object
|
|
if isinstance(path, str):
|
|
path_obj = Path(path)
|
|
else:
|
|
path_obj = path
|
|
|
|
# Check existence
|
|
if not path_obj.exists():
|
|
raise SecurityError(f"Path does not exist: {path}")
|
|
|
|
# Check read permission
|
|
if readable and not os.access(str(path_obj), os.R_OK):
|
|
raise SecurityError(f"Path not readable: {path}")
|
|
|
|
# Check write permission
|
|
if writable and not os.access(str(path_obj), os.W_OK):
|
|
raise SecurityError(f"Path not writable: {path}")
|
|
|
|
# Check execute permission
|
|
if executable and not os.access(str(path_obj), os.X_OK):
|
|
raise SecurityError(f"Path not executable: {path}")
|
|
|
|
return True
|
|
|
|
except SecurityError:
|
|
raise
|
|
except Exception as e:
|
|
raise SecurityError(f"Permission check failed: {e}") from e
|
|
|
|
@staticmethod
|
|
def is_symlink(path: str) -> bool:
|
|
"""Check if path is a symbolic link.
|
|
|
|
Args:
|
|
path: Path to check
|
|
|
|
Returns:
|
|
True if path is a symbolic link
|
|
"""
|
|
try:
|
|
if isinstance(path, str):
|
|
path = Path(path)
|
|
return path.is_symlink()
|
|
except (OSError, RuntimeError):
|
|
return False
|
|
|
|
@staticmethod
|
|
def resolve_symlink(path: str, follow_symlinks: bool = True) -> str:
|
|
"""Resolve symbolic links.
|
|
|
|
Args:
|
|
path: Path to resolve
|
|
follow_symlinks: If False, don't follow symlinks
|
|
|
|
Returns:
|
|
Resolved path
|
|
|
|
Raises:
|
|
SecurityError: If path cannot be resolved
|
|
"""
|
|
try:
|
|
if isinstance(path, str):
|
|
path = Path(path)
|
|
|
|
if follow_symlinks:
|
|
resolved = path.resolve()
|
|
else:
|
|
resolved = path
|
|
|
|
return str(resolved)
|
|
|
|
except (OSError, RuntimeError) as e:
|
|
raise SecurityError(f"Cannot resolve symlink: {e}") from e
|
|
|
|
@staticmethod
|
|
def validate_extension(
|
|
filename: str,
|
|
allowed_extensions: List[str]
|
|
) -> bool:
|
|
"""Validate file extension against allowed list.
|
|
|
|
Args:
|
|
filename: Filename to check
|
|
allowed_extensions: List of allowed extensions (with or without dot)
|
|
|
|
Returns:
|
|
True if extension is allowed
|
|
|
|
Raises:
|
|
SecurityError: If extension is not allowed
|
|
"""
|
|
try:
|
|
# Get file extension
|
|
path = Path(filename)
|
|
extension = path.suffix.lower()
|
|
|
|
# Normalize allowed extensions (ensure they start with dot)
|
|
normalized_allowed = [
|
|
ext if ext.startswith('.') else f'.{ext}'
|
|
for ext in allowed_extensions
|
|
]
|
|
|
|
if extension not in normalized_allowed:
|
|
raise SecurityError(
|
|
f"File extension '{extension}' not in allowed list: {normalized_allowed}"
|
|
)
|
|
|
|
return True
|
|
|
|
except SecurityError:
|
|
raise
|
|
except Exception as e:
|
|
raise SecurityError(f"Extension validation failed: {e}") from e
|
|
|
|
@staticmethod
|
|
def generate_safe_filename(
|
|
base_name: str,
|
|
extension: str = '',
|
|
add_timestamp: bool = False
|
|
) -> str:
|
|
"""Generate a safe filename.
|
|
|
|
Args:
|
|
base_name: Base filename
|
|
extension: File extension (with or without dot)
|
|
add_timestamp: Add timestamp to ensure uniqueness
|
|
|
|
Returns:
|
|
Safe filename
|
|
"""
|
|
try:
|
|
# Sanitize base name
|
|
safe_base = Security.sanitize_filename(base_name)
|
|
|
|
# Normalize extension
|
|
if extension and not extension.startswith('.'):
|
|
extension = f'.{extension}'
|
|
|
|
# Add timestamp if requested
|
|
if add_timestamp:
|
|
from datetime import datetime
|
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
safe_base = f"{safe_base}_{timestamp}"
|
|
|
|
# Combine
|
|
filename = f"{safe_base}{extension}"
|
|
|
|
# Final sanitization
|
|
return Security.sanitize_filename(filename)
|
|
|
|
except Exception as e:
|
|
raise SecurityError(f"Safe filename generation failed: {e}") from e
|