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
@@ -0,0 +1,439 @@
|
||||
"""
|
||||
Unit tests for src/shared/security_utils.py - Security utilities for path validation.
|
||||
|
||||
Covers:
|
||||
- Path traversal prevention (S002)
|
||||
- URL validation
|
||||
- Filename sanitization
|
||||
- Safe archive extraction
|
||||
|
||||
These tests verify the security hardening implemented to prevent common
|
||||
vulnerabilities like path traversal, zip slip attacks, and unsafe file operations.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
|
||||
class TestPathValidation:
|
||||
"""Test validate_path_within_root function (S002 fix)."""
|
||||
|
||||
def test_valid_relative_path(self):
|
||||
"""Verify paths within root are accepted."""
|
||||
from src.shared.security_utils import validate_path_within_root
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir) / "root"
|
||||
root.mkdir()
|
||||
child = root / "child"
|
||||
child.mkdir()
|
||||
|
||||
valid, msg = validate_path_within_root(child, root)
|
||||
assert valid is True
|
||||
assert msg == ""
|
||||
|
||||
def test_valid_file_in_subdirectory(self):
|
||||
"""Verify files nested in allowed directory are accepted."""
|
||||
from src.shared.security_utils import validate_path_within_root
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir) / "root"
|
||||
root.mkdir()
|
||||
file_path = root / "subdir" / "file.txt"
|
||||
file_path.parent.mkdir()
|
||||
file_path.touch()
|
||||
|
||||
valid, msg = validate_path_within_root(file_path, root)
|
||||
assert valid is True
|
||||
|
||||
def test_path_outside_root_rejected(self):
|
||||
"""Verify paths outside root are rejected (path traversal prevention)."""
|
||||
from src.shared.security_utils import validate_path_within_root
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir) / "root"
|
||||
root.mkdir()
|
||||
other_dir = Path(tmpdir) / "other"
|
||||
other_dir.mkdir()
|
||||
|
||||
valid, msg = validate_path_within_root(other_dir, root)
|
||||
assert valid is False
|
||||
assert "outside allowed root" in msg
|
||||
|
||||
def test_absolute_path_outside_rejected(self):
|
||||
"""Verify absolute paths outside root are rejected."""
|
||||
from src.shared.security_utils import validate_path_within_root
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir) / "root"
|
||||
root.mkdir()
|
||||
|
||||
# Try to access /etc/passwd or similar system path
|
||||
dangerous_path = Path("/etc/passwd") if os.path.exists("/etc/passwd") else Path("/nonexistent")
|
||||
|
||||
valid, msg = validate_path_within_root(dangerous_path, root)
|
||||
assert valid is False
|
||||
|
||||
def test_symlink_rejected_when_disabled(self):
|
||||
"""Verify symlinks are rejected when allow_symlinks=False."""
|
||||
from src.shared.security_utils import validate_path_within_root
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
target = Path(tmpdir) / "target"
|
||||
target.mkdir()
|
||||
|
||||
symlink = Path(tmpdir) / "symlink"
|
||||
# Create symlink if platform supports it
|
||||
if hasattr(os, 'symlink'):
|
||||
try:
|
||||
os.symlink(target, symlink)
|
||||
|
||||
valid, msg = validate_path_within_root(symlink, Path(tmpdir))
|
||||
assert valid is False
|
||||
assert "symlinks are not allowed" in msg
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("Symlinks not supported on this platform")
|
||||
|
||||
|
||||
class TestFilenameSanitization:
|
||||
"""Test sanitize_filename function."""
|
||||
|
||||
def test_safe_filename_unchanged(self):
|
||||
"""Verify safe filenames pass through unchanged."""
|
||||
from src.shared.security_utils import sanitize_filename
|
||||
|
||||
assert sanitize_filename("safe_file.txt") == "safe_file.txt"
|
||||
assert sanitize_filename("my_plugin.py") == "my_plugin.py"
|
||||
|
||||
def test_null_byte_removal(self):
|
||||
"""Verify null bytes (injection vector) are removed."""
|
||||
from src.shared.security_utils import sanitize_filename
|
||||
|
||||
result = sanitize_filename("file\x00.txt")
|
||||
assert '\x00' not in result
|
||||
assert "file" in result
|
||||
|
||||
def test_dangerous_characters_replaced(self):
|
||||
"""Verify dangerous characters are replaced with underscores."""
|
||||
from src.shared.security_utils import sanitize_filename
|
||||
|
||||
# Windows reserved characters
|
||||
result = sanitize_filename('file<name>.txt')
|
||||
assert '<' not in result
|
||||
assert '>' not in result
|
||||
|
||||
# Path traversal attempt
|
||||
result = sanitize_filename('../etc/passwd')
|
||||
assert '../' not in result
|
||||
|
||||
# Command injection attempt
|
||||
result = sanitize_filename('file; rm -rf /')
|
||||
assert ';' not in result
|
||||
|
||||
def test_path_separators_replaced(self):
|
||||
"""Verify path separators are replaced (traversal prevention)."""
|
||||
from src.shared.security_utils import sanitize_filename
|
||||
|
||||
# Unix path separator
|
||||
result = sanitize_filename("dir/file.txt")
|
||||
assert '/' not in result
|
||||
|
||||
# Windows path separator
|
||||
result = sanitize_filename("dir\\file.txt")
|
||||
assert '\\' not in result
|
||||
|
||||
def test_consecutive_underscores_collapsed(self):
|
||||
"""Verify multiple underscores are collapsed to one."""
|
||||
from src.shared.security_utils import sanitize_filename
|
||||
|
||||
result = sanitize_filename("file___name.txt")
|
||||
assert "___" not in result
|
||||
assert "__" not in result
|
||||
|
||||
def test_trailing_dots_stripped(self):
|
||||
"""Verify trailing dots (Windows reserved) are stripped."""
|
||||
from src.shared.security_utils import sanitize_filename
|
||||
|
||||
result = sanitize_filename("file...txt")
|
||||
assert not result.startswith('.')
|
||||
assert not result.endswith('.')
|
||||
|
||||
def test_max_length_truncation(self):
|
||||
"""Verify long filenames are truncated to max length."""
|
||||
from src.shared.security_utils import sanitize_filename
|
||||
|
||||
long_name = "a" * 300 + ".txt"
|
||||
result = sanitize_filename(long_name, max_length=255)
|
||||
assert len(result) <= 255
|
||||
|
||||
def test_empty_result_fallback(self):
|
||||
"""Verify empty input gets fallback filename."""
|
||||
from src.shared.security_utils import sanitize_filename
|
||||
|
||||
result = sanitize_filename("")
|
||||
assert result == "unnamed_file"
|
||||
|
||||
result = sanitize_filename("..")
|
||||
assert result == "unnamed_file"
|
||||
|
||||
|
||||
class TestSafeJoinPath:
|
||||
"""Test safe_join_path function."""
|
||||
|
||||
def test_normal_join_succeeds(self):
|
||||
"""Verify normal path joining works correctly."""
|
||||
from src.shared.security_utils import safe_join_path
|
||||
|
||||
result = safe_join_path("/tmp/plugins", "my_plugin", "__init__.py")
|
||||
assert result is not None
|
||||
assert str(result).endswith("my_plugin/__init__.py")
|
||||
|
||||
def test_traversal_attempt_blocked(self):
|
||||
"""Verify path traversal attempts return None."""
|
||||
from src.shared.security_utils import safe_join_path
|
||||
|
||||
result = safe_join_path("/tmp/plugins", "../../../etc/passwd")
|
||||
assert result is None
|
||||
|
||||
def test_absolute_path_in_parts_blocked(self):
|
||||
"""Verify absolute paths in parts are blocked."""
|
||||
from src.shared.security_utils import safe_join_path
|
||||
|
||||
result = safe_join_path("/tmp/plugins", "/etc/passwd")
|
||||
assert result is None
|
||||
|
||||
def test_sanitized_result(self):
|
||||
"""Verify returned path has sanitized components."""
|
||||
from src.shared.security_utils import safe_join_path
|
||||
|
||||
result = safe_join_path("/tmp/plugins", "my_plugin; rm -rf /", "file.txt")
|
||||
assert result is not None
|
||||
assert ';' not in str(result)
|
||||
|
||||
|
||||
class TestValidatePluginPath:
|
||||
"""Test validate_plugin_path function (S002 fix)."""
|
||||
|
||||
def test_valid_python_file(self):
|
||||
"""Verify valid .py plugin files are accepted."""
|
||||
from src.shared.security_utils import validate_plugin_path
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir) / "plugins"
|
||||
root.mkdir()
|
||||
plugin_file = root / "my_plugin.py"
|
||||
plugin_file.touch()
|
||||
|
||||
valid, msg = validate_plugin_path(plugin_file, root)
|
||||
assert valid is True
|
||||
|
||||
def test_valid_plugin_directory(self):
|
||||
"""Verify plugin directories with __init__.py are accepted."""
|
||||
from src.shared.security_utils import validate_plugin_path
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir) / "plugins"
|
||||
root.mkdir()
|
||||
plugin_dir = root / "my_plugin"
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / "__init__.py").touch()
|
||||
|
||||
valid, msg = validate_plugin_path(plugin_dir, root)
|
||||
assert valid is True
|
||||
|
||||
def test_missing_init_file_rejected(self):
|
||||
"""Verify directories without __init__.py are rejected."""
|
||||
from src.shared.security_utils import validate_plugin_path
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir) / "plugins"
|
||||
root.mkdir()
|
||||
plugin_dir = root / "my_plugin"
|
||||
plugin_dir.mkdir()
|
||||
# No __init__.py
|
||||
|
||||
valid, msg = validate_plugin_path(plugin_dir, root)
|
||||
assert valid is False
|
||||
assert "__init__.py" in msg
|
||||
|
||||
def test_non_python_file_rejected(self):
|
||||
"""Verify non-.py files are rejected."""
|
||||
from src.shared.security_utils import validate_plugin_path
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir) / "plugins"
|
||||
root.mkdir()
|
||||
bad_file = root / "script.sh"
|
||||
bad_file.touch()
|
||||
|
||||
valid, msg = validate_plugin_path(bad_file, root)
|
||||
assert valid is False
|
||||
|
||||
def test_suspicious_filename_rejected(self):
|
||||
"""Verify suspicious filenames (System32, etc.) are rejected."""
|
||||
from src.shared.security_utils import validate_plugin_path
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir) / "plugins"
|
||||
root.mkdir()
|
||||
bad_file = root / "system32.py"
|
||||
bad_file.touch()
|
||||
|
||||
valid, msg = validate_plugin_path(bad_file, root)
|
||||
assert valid is False
|
||||
|
||||
|
||||
class TestValidateDownloadUrl:
|
||||
"""Test validate_download_path function (S004 fix)."""
|
||||
|
||||
def test_valid_https_url(self):
|
||||
"""Verify valid HTTPS URLs are accepted."""
|
||||
from src.shared.security_utils import validate_download_path
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
valid, msg = validate_download_path(
|
||||
"https://example.com/update.zip",
|
||||
tmpdir
|
||||
)
|
||||
assert valid is True
|
||||
|
||||
def test_http_url_accepted(self):
|
||||
"""Verify HTTP URLs are accepted (though less secure)."""
|
||||
from src.shared.security_utils import validate_download_path
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
valid, msg = validate_download_path(
|
||||
"http://example.com/update.zip",
|
||||
tmpdir
|
||||
)
|
||||
assert valid is True
|
||||
|
||||
def test_invalid_scheme_rejected(self):
|
||||
"""Verify non-http/https URLs are rejected."""
|
||||
from src.shared.security_utils import validate_download_path
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
valid, msg = validate_download_path(
|
||||
"ftp://example.com/file.zip",
|
||||
tmpdir
|
||||
)
|
||||
assert valid is False
|
||||
|
||||
def test_encoded_traversal_detected(self):
|
||||
"""Verify URL-encoded traversal patterns are detected."""
|
||||
from src.shared.security_utils import validate_download_path
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# URL-encoded dots (%2e%2e = ..)
|
||||
valid, msg = validate_download_path(
|
||||
"https://example.com/%2e%2e/etc/passwd",
|
||||
tmpdir
|
||||
)
|
||||
assert valid is False
|
||||
|
||||
|
||||
class TestSafeExtractArchive:
|
||||
"""Test safe_extract_archive function (S002 fix - zip slip prevention)."""
|
||||
|
||||
def test_normal_archive_extracts(self):
|
||||
"""Verify normal archives extract successfully."""
|
||||
from src.shared.security_utils import safe_extract_archive
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
archive_path = Path(tmpdir) / "test.zip"
|
||||
target_dir = Path(tmpdir) / "extracted"
|
||||
target_dir.mkdir()
|
||||
|
||||
# Create test archive with normal content
|
||||
with zipfile.ZipFile(archive_path, 'w') as zf:
|
||||
zf.writestr("file.txt", "Hello World")
|
||||
zf.writestr("subdir/nested.txt", "Nested Content")
|
||||
|
||||
success, paths = safe_extract_archive(archive_path, target_dir)
|
||||
|
||||
assert success is True
|
||||
assert len(paths) == 2
|
||||
# Verify files were actually created
|
||||
assert (target_dir / "file.txt").exists()
|
||||
assert (target_dir / "subdir" / "nested.txt").exists()
|
||||
|
||||
def test_traversal_in_archive_blocked(self):
|
||||
"""Verify archives with traversal paths are rejected."""
|
||||
from src.shared.security_utils import safe_extract_archive
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
archive_path = Path(tmpdir) / "malicious.zip"
|
||||
target_dir = Path(tmpdir) / "extracted"
|
||||
target_dir.mkdir()
|
||||
|
||||
# Create malicious archive with traversal path
|
||||
with zipfile.ZipFile(archive_path, 'w') as zf:
|
||||
# This simulates a zip slip attack
|
||||
zf.writestr("../../../tmp/malicious.txt", "Malicious Content")
|
||||
|
||||
# Should raise ValueError due to path traversal
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
safe_extract_archive(archive_path, target_dir)
|
||||
|
||||
assert "path traversal" in str(exc_info.value).lower() or "unsafe" in str(exc_info.value).lower()
|
||||
|
||||
def test_file_limit_enforced(self):
|
||||
"""Verify file limit (DoS protection) is enforced."""
|
||||
from src.shared.security_utils import safe_extract_archive
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
archive_path = Path(tmpdir) / "large.zip"
|
||||
target_dir = Path(tmpdir) / "extracted"
|
||||
target_dir.mkdir()
|
||||
|
||||
# Create archive with many files (simulating zip bomb)
|
||||
with zipfile.ZipFile(archive_path, 'w') as zf:
|
||||
for i in range(10): # Small number for test
|
||||
zf.writestr(f"file_{i}.txt", f"Content {i}")
|
||||
|
||||
# Try to extract with limit of 5 files
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
safe_extract_archive(archive_path, target_dir, max_files=5)
|
||||
|
||||
assert "exceeded maximum file limit" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
class TestSecurityIntegration:
|
||||
"""Integration tests for security utilities working together."""
|
||||
|
||||
def test_full_download_and_extraction_pipeline(self):
|
||||
"""Verify full pipeline from URL validation to safe extraction."""
|
||||
from src.shared.security_utils import (
|
||||
validate_download_path,
|
||||
sanitize_filename,
|
||||
safe_join_path,
|
||||
safe_extract_archive
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Step 1: Validate URL
|
||||
url = "https://example.com/update.zip"
|
||||
valid, msg = validate_download_path(url, tmpdir)
|
||||
assert valid is True
|
||||
|
||||
# Step 2: Sanitize filename from URL
|
||||
filename = sanitize_filename(os.path.basename(url))
|
||||
assert 'update' in filename and '.zip' in filename
|
||||
|
||||
# Step 3: Safe path joining
|
||||
safe_path = safe_join_path(tmpdir, filename)
|
||||
assert safe_path is not None
|
||||
|
||||
# Step 4: Create and extract test archive
|
||||
with zipfile.ZipFile(safe_path, 'w') as zf:
|
||||
zf.writestr("content.txt", "Test")
|
||||
|
||||
extract_dir = Path(tmpdir) / "extracted"
|
||||
extract_dir.mkdir()
|
||||
|
||||
success, paths = safe_extract_archive(safe_path, extract_dir)
|
||||
assert success is True
|
||||
Reference in new issue
Block a user