- Extract path validation logic into shared helper function in
backend/app/utils/path_utils.py (validate_log_path)
- Refactor AddLogPathRequest to use the helper function
- Apply the same validation to DELETE /api/config/jails/{name}/logpath
endpoint by validating the log_path query parameter
- Return HTTP 422 with descriptive error if validation fails
- Add comprehensive unit tests for path validation
- Update Backend-Development.md with usage examples
This prevents path-traversal attacks on the delete_log_path endpoint
by ensuring all log paths are within allowlisted directories.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
150 lines
5.6 KiB
Python
150 lines
5.6 KiB
Python
"""Unit tests for path validation utilities."""
|
|
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from app.config import Settings
|
|
from app.utils.path_utils import validate_log_path
|
|
|
|
|
|
@pytest.fixture
|
|
def _mock_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Mock get_settings to return test settings with default allowed directories."""
|
|
def mock_get_settings() -> Settings:
|
|
return Settings(
|
|
database_path=":memory:",
|
|
fail2ban_socket="/tmp/fake.sock",
|
|
fail2ban_config_dir="/tmp/fail2ban",
|
|
session_secret="test-secret-key-do-not-use",
|
|
)
|
|
|
|
monkeypatch.setattr("app.utils.path_utils.get_settings", mock_get_settings)
|
|
|
|
|
|
def test_validate_log_path_valid_in_var_log(_mock_settings: None) -> None:
|
|
"""Valid log paths in /var/log are accepted."""
|
|
result = validate_log_path("/var/log/auth.log")
|
|
assert result == "/var/log/auth.log"
|
|
|
|
|
|
def test_validate_log_path_valid_in_config_log(_mock_settings: None) -> None:
|
|
"""Valid log paths in /config/log are accepted."""
|
|
result = validate_log_path("/config/log/app.log")
|
|
assert result == "/config/log/app.log"
|
|
|
|
|
|
def test_validate_log_path_valid_with_subdirectory(_mock_settings: None) -> None:
|
|
"""Log paths in subdirectories of allowed paths are accepted."""
|
|
result = validate_log_path("/var/log/syslog/auth.log")
|
|
assert result == "/var/log/syslog/auth.log"
|
|
|
|
|
|
def test_validate_log_path_rejects_path_outside_allowed(_mock_settings: None) -> None:
|
|
"""Paths outside allowed directories are rejected."""
|
|
with pytest.raises(ValueError) as exc_info:
|
|
validate_log_path("/etc/passwd")
|
|
error_msg = str(exc_info.value)
|
|
assert "outside allowed directories" in error_msg
|
|
assert "/etc/passwd" in error_msg
|
|
|
|
|
|
def test_validate_log_path_rejects_home_directory(_mock_settings: None) -> None:
|
|
"""Paths in home directories are rejected."""
|
|
with pytest.raises(ValueError) as exc_info:
|
|
validate_log_path("/home/user/app.log")
|
|
error_msg = str(exc_info.value)
|
|
assert "outside allowed directories" in error_msg
|
|
|
|
|
|
def test_validate_log_path_rejects_shadow_file(_mock_settings: None) -> None:
|
|
"""Paths to sensitive files like /etc/shadow are rejected."""
|
|
with pytest.raises(ValueError) as exc_info:
|
|
validate_log_path("/etc/shadow")
|
|
error_msg = str(exc_info.value)
|
|
assert "outside allowed directories" in error_msg
|
|
|
|
|
|
def test_validate_log_path_rejects_symlink_escape(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Symlinks that escape allowed directories are rejected."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
allowed_dir = Path(tmpdir) / "allowed"
|
|
escape_dir = Path(tmpdir) / "escape"
|
|
allowed_dir.mkdir()
|
|
escape_dir.mkdir()
|
|
|
|
symlink = allowed_dir / "escape_link"
|
|
symlink.symlink_to(escape_dir)
|
|
|
|
def mock_get_settings() -> Settings:
|
|
return Settings(
|
|
database_path=":memory:",
|
|
fail2ban_socket="/tmp/fake.sock",
|
|
fail2ban_config_dir="/tmp/fail2ban",
|
|
session_secret="test-secret-key-do-not-use",
|
|
allowed_log_dirs=[str(allowed_dir)],
|
|
)
|
|
|
|
monkeypatch.setattr("app.utils.path_utils.get_settings", mock_get_settings)
|
|
|
|
# Accessing the symlink resolves it to the escape_dir, which is outside the allowed_dir.
|
|
with pytest.raises(ValueError) as exc_info:
|
|
validate_log_path(str(symlink / "evil.log"))
|
|
error_msg = str(exc_info.value)
|
|
assert "outside allowed directories" in error_msg
|
|
|
|
|
|
def test_validate_log_path_rejects_prefix_bypass(_mock_settings: None) -> None:
|
|
"""Paths that are similar to allowed paths but outside (e.g., /var/log_evil) are rejected."""
|
|
with pytest.raises(ValueError) as exc_info:
|
|
validate_log_path("/var/log_evil/somefile.log")
|
|
error_msg = str(exc_info.value)
|
|
assert "outside allowed directories" in error_msg
|
|
|
|
|
|
def test_validate_log_path_returns_unchanged_value(_mock_settings: None) -> None:
|
|
"""The returned value is exactly what was passed in."""
|
|
path = "/var/log/app.log"
|
|
result = validate_log_path(path)
|
|
assert result is path or result == path
|
|
|
|
|
|
def test_validate_log_path_rejects_custom_allowed_dir_outside(
|
|
_mock_settings: None, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Paths outside custom allowed directories are rejected."""
|
|
def mock_get_settings() -> Settings:
|
|
return Settings(
|
|
database_path=":memory:",
|
|
fail2ban_socket="/tmp/fake.sock",
|
|
fail2ban_config_dir="/tmp/fail2ban",
|
|
session_secret="test-secret-key-do-not-use",
|
|
allowed_log_dirs=["/custom/logs"],
|
|
)
|
|
|
|
monkeypatch.setattr("app.utils.path_utils.get_settings", mock_get_settings)
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
validate_log_path("/var/log/app.log")
|
|
error_msg = str(exc_info.value)
|
|
assert "outside allowed directories" in error_msg
|
|
assert "/custom/logs" in error_msg
|
|
|
|
|
|
def test_validate_log_path_accepts_custom_allowed_dir(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Paths within custom allowed directories are accepted."""
|
|
def mock_get_settings() -> Settings:
|
|
return Settings(
|
|
database_path=":memory:",
|
|
fail2ban_socket="/tmp/fake.sock",
|
|
fail2ban_config_dir="/tmp/fail2ban",
|
|
session_secret="test-secret-key-do-not-use",
|
|
allowed_log_dirs=["/custom/logs"],
|
|
)
|
|
|
|
monkeypatch.setattr("app.utils.path_utils.get_settings", mock_get_settings)
|
|
|
|
result = validate_log_path("/custom/logs/app.log")
|
|
assert result == "/custom/logs/app.log"
|