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
+48
-19
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user