security: implement pentest remediation (RATE-002, CLI-001)

RATE-002: Proactive rate limit cleanup when entries exceed threshold
- Add RATE_LIMIT_CLEANUP_THRESHOLD config (default 0.8)
- Trigger cleanup before hitting hard limit
- Prevents memory exhaustion under sustained load

CLI-001: Validate clipboard tool paths against trusted directories
- Add TRUSTED_CLIPBOARD_DIRS for Unix system paths
- Add TRUSTED_WINDOWS_PATTERNS for Windows validation
- Reject tools in user-writable locations (PATH hijack prevention)
- Use absolute paths in subprocess calls
This commit is contained in:
Username
2025-12-24 22:03:17 +01:00
parent 89eee3378a
commit 1fbb69d7f9
6 changed files with 240 additions and 6 deletions

View File

@@ -0,0 +1,96 @@
"""Tests for CLI security features (CLI-001: clipboard tool validation)."""
import importlib.machinery
import importlib.util
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
def load_fpaste_module():
"""Load the fpaste script as a module."""
fpaste_path = Path(__file__).parent.parent / "fpaste"
spec = importlib.util.spec_from_loader(
"fpaste",
importlib.machinery.SourceFileLoader("fpaste", str(fpaste_path)),
)
if spec is None or spec.loader is None:
pytest.skip("Could not load fpaste module")
fpaste = importlib.util.module_from_spec(spec)
sys.modules["fpaste"] = fpaste
spec.loader.exec_module(fpaste)
return fpaste
class TestClipboardPathValidation:
"""Tests for CLI-001: clipboard tool path validation."""
@pytest.fixture
def fpaste(self):
"""Load fpaste module."""
return load_fpaste_module()
def test_trusted_unix_paths(self, fpaste):
"""Paths in trusted Unix directories should be accepted."""
assert fpaste.is_trusted_clipboard_path("/usr/bin/xclip") is True
assert fpaste.is_trusted_clipboard_path("/usr/local/bin/xsel") is True
assert fpaste.is_trusted_clipboard_path("/bin/cat") is True
assert fpaste.is_trusted_clipboard_path("/opt/homebrew/bin/pbcopy") is True
def test_untrusted_unix_paths(self, fpaste):
"""Paths in user-writable directories should be rejected."""
assert fpaste.is_trusted_clipboard_path("/home/user/bin/xclip") is False
assert fpaste.is_trusted_clipboard_path("/tmp/xclip") is False
assert fpaste.is_trusted_clipboard_path("/var/tmp/malicious") is False
assert fpaste.is_trusted_clipboard_path("./xclip") is False
assert fpaste.is_trusted_clipboard_path("") is False
def test_none_path_rejected(self, fpaste):
"""None path should be rejected."""
# is_trusted_clipboard_path should handle None gracefully
assert fpaste.is_trusted_clipboard_path(None) is False
@pytest.mark.skipif(sys.platform != "win32", reason="Windows path tests only run on Windows")
def test_trusted_windows_paths(self, fpaste):
"""Paths in trusted Windows directories should be accepted."""
# Test Windows paths (case-insensitive)
assert fpaste.is_trusted_clipboard_path("C:\\Windows\\System32\\clip.exe") is True
assert fpaste.is_trusted_clipboard_path("C:\\WINDOWS\\SYSTEM32\\clip.exe") is True
assert fpaste.is_trusted_clipboard_path("C:\\Program Files\\tool.exe") is True
def test_untrusted_windows_paths(self, fpaste):
"""Paths in user-writable Windows directories should be rejected."""
assert fpaste.is_trusted_clipboard_path("C:\\Users\\user\\xclip.exe") is False
assert fpaste.is_trusted_clipboard_path("C:\\Temp\\malicious.exe") is False
def test_find_clipboard_command_rejects_untrusted(self, fpaste):
"""find_clipboard_command should reject tools in untrusted paths."""
with patch("shutil.which") as mock_which:
# Untrusted path should be rejected
mock_which.return_value = "/tmp/malicious/xclip"
result = fpaste.find_clipboard_command(fpaste.CLIPBOARD_READ_COMMANDS)
assert result is None
def test_find_clipboard_command_accepts_trusted(self, fpaste):
"""find_clipboard_command should accept tools in trusted paths."""
with patch("shutil.which") as mock_which:
# Trusted path should be accepted
mock_which.return_value = "/usr/bin/xclip"
result = fpaste.find_clipboard_command(fpaste.CLIPBOARD_READ_COMMANDS)
assert result is not None
assert result[0] == "/usr/bin/xclip"
def test_find_clipboard_uses_absolute_path(self, fpaste):
"""find_clipboard_command should use absolute paths."""
with patch("shutil.which") as mock_which:
mock_which.return_value = "/usr/bin/xclip"
result = fpaste.find_clipboard_command(fpaste.CLIPBOARD_READ_COMMANDS)
# Should use absolute path, not just tool name
assert result[0] == "/usr/bin/xclip"
# Rest of command args should be preserved
assert "-selection" in result
assert "clipboard" in result

View File

@@ -267,3 +267,75 @@ class TestRateLimitMaxEntries:
finally:
app.config["RATE_LIMIT_MAX_ENTRIES"] = original_max
reset_rate_limits()
class TestRateLimitCleanupThreshold:
"""Tests for RATE-002: automatic cleanup trigger when threshold exceeded."""
def test_cleanup_threshold_config_exists(self, app):
"""RATE_LIMIT_CLEANUP_THRESHOLD config should exist."""
assert "RATE_LIMIT_CLEANUP_THRESHOLD" in app.config
threshold = app.config["RATE_LIMIT_CLEANUP_THRESHOLD"]
assert isinstance(threshold, float)
assert 0.0 < threshold <= 1.0
def test_proactive_cleanup_at_threshold(self, app):
"""Cleanup triggers proactively when threshold exceeded."""
from app.api.routes import (
_rate_limit_requests,
check_rate_limit,
reset_rate_limits,
)
reset_rate_limits()
original_max = app.config.get("RATE_LIMIT_MAX_ENTRIES", 10000)
original_threshold = app.config.get("RATE_LIMIT_CLEANUP_THRESHOLD", 0.8)
# Set max to 10, threshold to 0.5 (cleanup at 5 entries)
app.config["RATE_LIMIT_MAX_ENTRIES"] = 10
app.config["RATE_LIMIT_CLEANUP_THRESHOLD"] = 0.5
try:
with app.app_context():
# Add 6 entries (exceeds 50% threshold of 10)
for i in range(6):
check_rate_limit(f"10.0.0.{i}", authenticated=False)
# Threshold cleanup should have triggered, reducing count
# Cleanup prunes to threshold/2 = 2-3 entries
assert len(_rate_limit_requests) < 6
finally:
app.config["RATE_LIMIT_MAX_ENTRIES"] = original_max
app.config["RATE_LIMIT_CLEANUP_THRESHOLD"] = original_threshold
reset_rate_limits()
def test_no_cleanup_below_threshold(self, app):
"""No cleanup when below threshold."""
from app.api.routes import (
_rate_limit_requests,
check_rate_limit,
reset_rate_limits,
)
reset_rate_limits()
original_max = app.config.get("RATE_LIMIT_MAX_ENTRIES", 10000)
original_threshold = app.config.get("RATE_LIMIT_CLEANUP_THRESHOLD", 0.8)
# Set max to 20, threshold to 0.8 (cleanup at 16 entries)
app.config["RATE_LIMIT_MAX_ENTRIES"] = 20
app.config["RATE_LIMIT_CLEANUP_THRESHOLD"] = 0.8
try:
with app.app_context():
# Add 5 entries (below 80% threshold of 20 = 16)
for i in range(5):
check_rate_limit(f"10.0.0.{i}", authenticated=False)
# All 5 entries should remain (no cleanup triggered)
assert len(_rate_limit_requests) == 5
finally:
app.config["RATE_LIMIT_MAX_ENTRIES"] = original_max
app.config["RATE_LIMIT_CLEANUP_THRESHOLD"] = original_threshold
reset_rate_limits()