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

+48 -19
View File
@@ -1,9 +1,9 @@
"""
Filename: updater.py
Description: Generic updater file
Description: Generic updater file with security hardening
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
Author: Tyler de Zeeuw & Hermes
License: GPL-3.0
"""
@@ -23,12 +23,21 @@ from typing import List, Tuple, Optional, Callable, Any
# External library imports
import psutil
import requests
import requests # Uses urllib3 with certifi CA bundle (S004)
from PySide6.QtCore import QThread, Signal, QObject
from PySide6.QtWidgets import QMainWindow, QMessageBox
from src.shared.shareddata import get_app_dir
# Security utilities - prevents path traversal and unsafe operations (S002)
from src.shared.security_utils import (
validate_path_within_root,
safe_join_path,
sanitize_filename,
validate_download_path,
safe_extract_archive
)
def version_compare(v1: str, v2: str) -> int:
"""Compare two version strings (e.g., 1.2.3 vs 1.2.4). Returns 1 if v1>v2, -1 if v1<v2, 0 if equal."""
@@ -65,7 +74,13 @@ class UpdateDownloadThread(QThread):
def run(self) -> None:
try:
local_filename = os.path.basename(self.download_url)
# SECURITY FIX (S004): Validate download URL before fetching
valid, msg = validate_download_path(self.download_url, get_app_dir())
if not valid:
self.error_occurred.emit(f"Download validation failed: {msg}")
return
local_filename = sanitize_filename(os.path.basename(self.download_url))
if self.platform_name == 'darwin':
tmp_dir = f'/tmp/{self.app_name}tempupdate'
@@ -75,35 +90,39 @@ class UpdateDownloadThread(QThread):
tmp_dir = get_app_dir()
local_path = os.path.join(tmp_dir, local_filename)
# Download the file
with requests.get(self.download_url, stream=True, timeout=15) as r:
# SECURITY FIX (S004): Use verified SSL context via requests library
# requests uses urllib3 which validates certificates against certifi bundle by default
with requests.get(self.download_url, stream=True, timeout=15, verify=True) as r:
r.raise_for_status()
with open(local_path, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
# Extract folder name (remove .zip)
# SECURITY FIX (S002): Sanitize extract path
extract_folder = sanitize_filename(os.path.splitext(local_filename)[0])
if self.platform_name == 'darwin':
extract_folder = os.path.splitext(local_filename)[0]
extract_path = os.path.join(tmp_dir, extract_folder)
extract_path = safe_join_path(tmp_dir, extract_folder)
else:
extract_folder = os.path.splitext(local_filename)[0]
extract_path = os.path.join(get_app_dir(), extract_folder)
extract_path = safe_join_path(get_app_dir(), extract_folder)
if extract_path is None:
self.error_occurred.emit("Invalid extraction path detected")
return
# Create the folder if not exists
os.makedirs(extract_path, exist_ok=True)
# Extract the zip file contents
if self.platform_name == 'darwin':
subprocess.run(['ditto', '-xk', local_path, extract_path], check=True)
else:
with zipfile.ZipFile(local_path, 'r') as zip_ref:
zip_ref.extractall(extract_path)
# SECURITY FIX (S002): Use safe extraction to prevent zip slip attacks
success, extracted = safe_extract_archive(local_path, extract_path)
if not success:
self.error_occurred.emit("Archive extraction failed due to security validation")
return
# Remove the zip once extracted and emit a signal
os.remove(local_path)
self.update_ready.emit(self.latest_version, extract_path)
self.update_ready.emit(self.latest_version, str(extract_path))
except Exception as e:
# Emit a signal signifying failure
@@ -374,6 +393,16 @@ class UpdateManager(QObject):
def install_update(self, extract_folder: str) -> None:
base_dir = get_app_dir()
# SECURITY FIX (S002): Validate extract_folder is within allowed directory
valid, msg = validate_path_within_root(extract_folder, base_dir)
if not valid:
QMessageBox.critical(
self.main_window,
"Security Error",
f"Extraction path validation failed:\n{msg}"
)
return
# Path to updater executable
if self.platform_name == 'windows':
@@ -400,7 +429,7 @@ class UpdateManager(QObject):
print(f'Launching updater with: "{updater_path}" "{extract_folder}" "{main_app_executable}"')
# SECURITY FIX: Quote path arguments to prevent command injection
# SECURITY FIX (S001): Quote path arguments to prevent command injection
if self.platform_name == 'darwin':
subprocess.Popen(['open', shlex.quote(updater_path), '--args', shlex.quote(extract_folder), shlex.quote(main_app_executable)])
else: