security: Fix path traversal and add SSL verification hardening
Addresses audit findings S002 (Path Traversal) and S004 (SSL Verification): S002 - Path Traversal Vulnerabilities (CWE-22): - Created src/shared/security_utils.py with centralized security functions: * validate_path_within_root() - Ensures paths stay within allowed directories * safe_join_path() - Safe path joining with traversal detection * sanitize_filename() - Removes dangerous characters from filenames * validate_plugin_path() - Specialized validation for plugin loading * validate_download_path() - URL validation before downloads * safe_extract_archive() - Prevents zip slip attacks during extraction - Hardened updater.py: * Added path validation in UpdateDownloadThread.run() (lines ~74-120) * Added path validation in install_update() (lines ~394-403) * Sanitized filenames from download URLs * Switched to safe_extract_archive() for archive handling - Hardened plugin_manager.py: * Added plugin path validation in reload_plugins() (lines ~101-127) * Added URL and archive validation in install_plugin_from_url() (lines ~330-368) * Integrated safe_extract_archive() to prevent zip slip attacks S004 - SSL Certificate Verification: - Added explicit verify=True to requests.get() calls (updater.py line ~92) - Documented that certifi provides CA bundle for SSL verification - Verified urllib3/requests use certifi by default Test Coverage: - tests/unit/test_security_utils.py: 28 new tests covering all security functions * Path traversal prevention tests * Filename sanitization tests * URL validation tests * Archive extraction security tests
This commit is contained in:
1 parent
3db86754b3
commit
2d68197272
4 files changed
+861
-28
No files matched your search
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
Security utilities for path validation, input sanitization, and safe file operations.
|
||||
|
||||
This module provides centralized security functions used across the application
|
||||
to prevent common vulnerabilities like path traversal (CWE-22), command injection
|
||||
(CWE-78), and unsafe file operations.
|
||||
|
||||
Author: Hermes (security remediation)
|
||||
License: GPL-3.0
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Union, Optional
|
||||
|
||||
|
||||
def validate_path_within_root(
|
||||
path: Union[str, Path],
|
||||
root: Union[str, Path],
|
||||
allow_symlinks: bool = False
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate that a path is safely contained within the allowed root directory.
|
||||
|
||||
Prevents path traversal attacks by ensuring the resolved absolute path
|
||||
starts with the root directory prefix.
|
||||
|
||||
Args:
|
||||
path: The path to validate.
|
||||
root: The root directory that the path must be within.
|
||||
allow_symlinks: If False, reject symlinks (default for security).
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message):
|
||||
- is_valid: True if path is safe, False otherwise.
|
||||
- error_message: Description of why validation failed, or empty string.
|
||||
|
||||
Example:
|
||||
>>> valid, msg = validate_path_within_root(
|
||||
... "/tmp/plugins/my_plugin",
|
||||
... "/tmp/plugins"
|
||||
... )
|
||||
>>> assert valid is True
|
||||
|
||||
>>> valid, msg = validate_path_within_root(
|
||||
... "/etc/passwd",
|
||||
... "/tmp/plugins"
|
||||
... )
|
||||
>>> assert valid is False
|
||||
"""
|
||||
# Convert to Path objects for consistent handling
|
||||
path_obj = Path(path)
|
||||
root_obj = Path(root)
|
||||
|
||||
# Resolve to absolute path (follows symlinks if they exist)
|
||||
try:
|
||||
abs_path = path_obj.resolve()
|
||||
abs_root = root_obj.resolve()
|
||||
except (OSError, RuntimeError) as e:
|
||||
return False, f"Invalid path resolution: {e}"
|
||||
|
||||
# Check if path is within root
|
||||
try:
|
||||
abs_path.relative_to(abs_root)
|
||||
except ValueError:
|
||||
return False, f"Path '{path}' is outside allowed root directory '{root}'"
|
||||
|
||||
# Optional symlink check for extra security
|
||||
if not allow_symlinks and path_obj.is_symlink():
|
||||
return False, f"Symlinks are not allowed for security reasons: {path}"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def sanitize_filename(filename: str, max_length: int = 255) -> str:
|
||||
"""
|
||||
Sanitize a filename by removing/replacing dangerous characters.
|
||||
|
||||
Args:
|
||||
filename: The filename to sanitize.
|
||||
max_length: Maximum allowed length (default 255 for filesystem compat).
|
||||
|
||||
Returns:
|
||||
Sanitized filename safe for use in file operations.
|
||||
|
||||
Example:
|
||||
>>> sanitize_filename("my_plugin.py")
|
||||
'my_plugin.py'
|
||||
|
||||
>>> sanitize_filename("../etc/passwd; rm -rf /")
|
||||
'_etc_passwd_rm_rf_'
|
||||
|
||||
>>> sanitize_filename("C:\\Windows\\System32\\config")
|
||||
'C_Windows_System32_config'
|
||||
"""
|
||||
# Remove null bytes (common injection vector)
|
||||
filename = filename.replace('\x00', '')
|
||||
|
||||
# Replace dangerous characters with underscores
|
||||
dangerous_chars = r'[<>:"|?*\/\\:\x00-\x1f]'
|
||||
sanitized = re.sub(dangerous_chars, '_', filename)
|
||||
|
||||
# Remove consecutive underscores
|
||||
sanitized = re.sub(r'_+', '_', sanitized)
|
||||
|
||||
# Strip leading/trailing dots and whitespace
|
||||
sanitized = sanitized.strip('. \t\r\n')
|
||||
|
||||
# Truncate to max length
|
||||
if len(sanitized) > max_length:
|
||||
# Preserve extension if present
|
||||
if '.' in sanitized:
|
||||
name, ext = sanitized.rsplit('.', 1)
|
||||
max_name_len = max_length - len(ext) - 1
|
||||
sanitized = f"{name[:max_name_len]}.{ext}"
|
||||
else:
|
||||
sanitized = sanitized[:max_length]
|
||||
|
||||
# Final fallback for empty result
|
||||
if not sanitized or sanitized in ('.', '..'):
|
||||
sanitized = 'unnamed_file'
|
||||
|
||||
return sanitized
|
||||
|
||||
|
||||
def safe_join_path(base: Union[str, Path], *parts: str) -> Optional[Path]:
|
||||
"""
|
||||
Safely join path components with validation against traversal.
|
||||
|
||||
Unlike os.path.join(), this function validates that the result
|
||||
doesn't escape the base directory using '..' or absolute paths.
|
||||
|
||||
Args:
|
||||
base: The base directory (must be absolute).
|
||||
*parts: Path components to join.
|
||||
|
||||
Returns:
|
||||
Resolved Path if safe, None if path traversal detected.
|
||||
|
||||
Example:
|
||||
>>> result = safe_join_path("/tmp/plugins", "my_plugin", "__init__.py")
|
||||
>>> assert str(result) == "/tmp/plugins/my_plugin/__init__.py"
|
||||
|
||||
>>> result = safe_join_path("/tmp/plugins", "../../../etc/passwd")
|
||||
>>> assert result is None # Blocked traversal attempt
|
||||
"""
|
||||
base_path = Path(base).resolve()
|
||||
|
||||
# Join all parts
|
||||
joined = base_path
|
||||
for part in parts:
|
||||
# Sanitize each component
|
||||
clean_part = sanitize_filename(os.path.basename(part))
|
||||
|
||||
# Check for traversal in original part
|
||||
if '..' in part.split(os.sep)[0] or part.startswith('/'):
|
||||
return None # Traversal attempt detected
|
||||
|
||||
joined = joined / clean_part
|
||||
|
||||
# Final validation
|
||||
try:
|
||||
resolved = joined.resolve()
|
||||
resolved.relative_to(base_path)
|
||||
return resolved
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def validate_plugin_path(
|
||||
plugin_path: Union[str, Path],
|
||||
plugins_root: Union[str, Path]
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Specialized validation for plugin file paths.
|
||||
|
||||
Ensures the plugin is within the allowed plugins directory and
|
||||
has an acceptable filename pattern (.py files or directories).
|
||||
|
||||
Args:
|
||||
plugin_path: The plugin path to validate.
|
||||
plugins_root: The root plugins directory.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message).
|
||||
"""
|
||||
# First check basic containment
|
||||
valid, msg = validate_path_within_root(plugin_path, plugins_root)
|
||||
if not valid:
|
||||
return False, f"Plugin path validation failed: {msg}"
|
||||
|
||||
path_obj = Path(plugin_path)
|
||||
|
||||
# Validate filename pattern
|
||||
if path_obj.is_file():
|
||||
if not path_obj.suffix == '.py':
|
||||
return False, f"Plugin files must have .py extension: {plugin_path}"
|
||||
|
||||
# Check for dangerous patterns in filename
|
||||
name_lower = path_obj.name.lower()
|
||||
if any(dangerous in name_lower for dangerous in ['system32', 'windows', 'etc']):
|
||||
return False, f"Suspicious filename detected: {path_obj.name}"
|
||||
|
||||
elif path_obj.is_dir():
|
||||
# For directories, check that __init__.py exists if it's meant to be a package
|
||||
init_file = path_obj / '__init__.py'
|
||||
if not init_file.exists():
|
||||
return False, f"Plugin directory must contain __init__.py: {plugin_path}"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_download_path(
|
||||
download_url: str,
|
||||
target_dir: Union[str, Path],
|
||||
allowed_extensions: tuple[str, ...] = ('.zip',)
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate a download URL and ensure target is safe.
|
||||
|
||||
Args:
|
||||
download_url: The URL to validate.
|
||||
target_dir: Directory where file will be downloaded.
|
||||
allowed_extensions: Allowed file extensions for downloads.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message).
|
||||
"""
|
||||
# Validate URL format
|
||||
if not download_url.startswith(('http://', 'https://')):
|
||||
return False, f"Invalid download URL (must be http/https): {download_url}"
|
||||
|
||||
# Check for suspicious URL patterns
|
||||
suspicious_patterns = [
|
||||
r'\/\/.*\/..', # Multiple slashes with traversal
|
||||
r'%2e%2e', # URL-encoded dots
|
||||
r'\.\.%2f', # Mixed encoding
|
||||
]
|
||||
|
||||
url_lower = download_url.lower()
|
||||
for pattern in suspicious_patterns:
|
||||
if re.search(pattern, url_lower):
|
||||
return False, f"Suspicious pattern detected in download URL"
|
||||
|
||||
# Validate target directory exists and is writable
|
||||
target_path = Path(target_dir)
|
||||
if not target_path.exists():
|
||||
try:
|
||||
target_path.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as e:
|
||||
return False, f"Cannot create download directory: {e}"
|
||||
|
||||
if not target_path.is_dir():
|
||||
return False, f"Target is not a directory: {target_path}"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def safe_extract_archive(
|
||||
archive_path: Union[str, Path],
|
||||
extract_to: Union[str, Path],
|
||||
max_files: int = 1000
|
||||
) -> tuple[bool, list[str]]:
|
||||
"""
|
||||
Safely extract an archive with validation against zip slip attacks.
|
||||
|
||||
Prevents extracting files outside the target directory by validating
|
||||
each path component during extraction.
|
||||
|
||||
Args:
|
||||
archive_path: Path to the archive file.
|
||||
extract_to: Target directory for extraction.
|
||||
max_files: Maximum number of files to extract (DoS protection).
|
||||
|
||||
Returns:
|
||||
Tuple of (success, list_of_extracted_paths).
|
||||
|
||||
Raises:
|
||||
ValueError: If path traversal detected in archive contents.
|
||||
RuntimeError: If file limit exceeded or other errors occur.
|
||||
"""
|
||||
import zipfile
|
||||
import shutil
|
||||
|
||||
extracted = []
|
||||
extract_path = Path(extract_to).resolve()
|
||||
|
||||
with zipfile.ZipFile(archive_path, 'r') as zip_ref:
|
||||
# First pass: validate all paths
|
||||
for info in zip_ref.infolist():
|
||||
# Check for path traversal in archive member name
|
||||
member_path = Path(info.filename)
|
||||
|
||||
# Normalize and check
|
||||
try:
|
||||
normalized = member_path.resolve()
|
||||
# Check if it would escape extract_to
|
||||
test_path = extract_path / member_path
|
||||
test_resolved = test_path.resolve()
|
||||
|
||||
if not str(test_resolved).startswith(str(extract_path)):
|
||||
raise ValueError(f"Path traversal detected in archive: {info.filename}")
|
||||
except Exception as e:
|
||||
raise ValueError(f"Unsafe path in archive: {info.filename}") from e
|
||||
|
||||
# Second pass: extract with file count limit
|
||||
extracted_count = 0
|
||||
for info in zip_ref.infolist():
|
||||
if extracted_count >= max_files:
|
||||
raise RuntimeError(f"Exceeded maximum file limit ({max_files})")
|
||||
|
||||
# Extract the file safely
|
||||
zip_ref.extract(info, extract_to)
|
||||
extracted.append(str(extract_path / info.filename))
|
||||
extracted_count += 1
|
||||
|
||||
return True, extracted
|
||||
Reference in new issue
Block a user