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
+56
-9
@@ -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."""
|
||||
|
||||
Reference in new issue
Block a user