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

+56 -9
View File
@@ -21,11 +21,20 @@ from pathlib import Path
from typing import Any, cast
# External library imports
import certifi
import certifi # Provides up-to-date CA certificates (security fix for S004)
from PySide6.QtCore import QObject, QThread, QUrl, Signal
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import QMainWindow, QMenu, QMessageBox
# Security utilities - prevents path traversal and unsafe operations (S002)
from src.shared.security_utils import (
validate_plugin_path,
safe_join_path,
sanitize_filename,
validate_download_path,
safe_extract_archive
)
from src.shared.shareddata import APP_NAME, CURRENT_VERSION, PLATFORM_NAME
@@ -89,14 +98,29 @@ class PluginManager(QObject):
self.unload_plugins()
plugins_dir_str = str(self.plugins_dir)
# SECURITY FIX (S002): Validate plugins directory path before use
valid, msg = validate_plugin_path(self.plugins_dir, self.plugins_dir)
if not valid:
print(f"[PluginManager] Invalid plugins directory: {msg}")
return []
if plugins_dir_str not in sys.path:
sys.path.insert(0, plugins_dir_str)
failed_plugins: list[tuple[str, str]] = []
for entry in sorted(self.plugins_dir.iterdir(), key=lambda p: p.name.lower()):
# Skip hidden files/dirs and disabled plugins
if entry.name.startswith((".", "__")) or entry.name.endswith(".disabled"):
continue
# SECURITY FIX (S002): Validate each plugin path to prevent traversal
valid, msg = validate_plugin_path(entry, self.plugins_dir)
if not valid:
print(f"[PluginManager] Skipping unsafe plugin: {msg}")
failed_plugins.append((entry.name, msg))
continue
module_name = ""
init_file: Path | None = None
@@ -306,23 +330,46 @@ class PluginManager(QObject):
def install_plugin_from_url(self, download_url: str, plugin_id: str = "") -> Path:
"""
Downloads a single-folder ZIP archive and extracts it directly into plugins/.
SECURITY FIX (S002): Validates URL, sanitizes paths, prevents zip slip attacks.
"""
# SECURITY FIX (S004): Validate download URL before fetching
valid, msg = validate_download_path(download_url, self.plugins_dir)
if not valid:
raise RuntimeError(f"Download validation failed: {msg}")
req = urllib.request.Request(
download_url,
headers={"User-Agent": f"{APP_NAME}-PluginManager"},
)
# SECURITY FIX (S004): Use SSL context with certifi CA bundle
with urllib.request.urlopen(req, timeout=15, context=get_ssl_context()) as response:
if response.status != 200:
raise RuntimeError(f"Download failed with HTTP status code {response.status}")
zip_bytes = response.read()
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zip_ref:
zip_ref.extractall(self.plugins_dir)
self.reload_plugins()
return self.plugins_dir
# SECURITY FIX (S002): Write to temp file first, then validate and extract
import tempfile
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
tmp.write(zip_bytes)
tmp_path = Path(tmp.name)
try:
# SECURITY FIX (S002): Safe extraction prevents zip slip attacks
success, extracted_paths = safe_extract_archive(tmp_path, self.plugins_dir)
if not success:
raise RuntimeError("Archive extraction failed due to security validation")
self.reload_plugins()
return self.plugins_dir
finally:
# Cleanup temp file
try:
tmp_path.unlink()
except OSError:
pass # Already removed or inaccessible
def open_plugins_directory(self) -> bool:
"""Opens the local plugins folder in the operating system's file manager."""