""" 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.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