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:
Hermes committed 2026-11-07 15:40:47 -07:00
1 parent 3db86754b3
commit 2d68197272
4 files changed
+861 -28

No files matched your search

+318
View File
@@ -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