7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
283 lines
11 KiB
Python
283 lines
11 KiB
Python
"""Coverage tests for security/path_validator.py.
|
|
|
|
Focuses on adversarial inputs and uncovered branches:
|
|
- Null bytes in all methods
|
|
- Path traversal attempts (../)
|
|
- URL-encoded traversal
|
|
- validate_local_filesystem_path: control chars, ~ expansion,
|
|
restricted dirs, relative paths
|
|
- validate_safe_path: extension filtering, None result
|
|
- sanitize_for_filesystem_ops: non-absolute path
|
|
- validate_config_path: restricted prefixes, absolute paths,
|
|
missing config files
|
|
- validate_model_path: missing/non-file model
|
|
- validate_data_path success/failure
|
|
"""
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from local_deep_research.security.path_validator import PathValidator
|
|
|
|
MODULE = "local_deep_research.security.path_validator"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# validate_safe_path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateSafePath:
|
|
def test_null_byte_raises(self):
|
|
with pytest.raises(ValueError, match="Null bytes"):
|
|
PathValidator.validate_safe_path("file\x00name.txt", "/tmp")
|
|
|
|
def test_empty_string_raises(self):
|
|
with pytest.raises(ValueError, match="Invalid path input"):
|
|
PathValidator.validate_safe_path("", "/tmp")
|
|
|
|
def test_none_input_raises(self):
|
|
with pytest.raises(ValueError, match="Invalid path input"):
|
|
PathValidator.validate_safe_path(None, "/tmp")
|
|
|
|
def test_path_traversal_blocked(self):
|
|
with pytest.raises(ValueError, match="traversal|Invalid path"):
|
|
PathValidator.validate_safe_path("../etc/passwd", "/tmp")
|
|
|
|
def test_double_dot_deeply_nested_allowed_by_werkzeug(self):
|
|
"""werkzeug safe_join resolves a/b/../../etc/passwd within the base, so it's
|
|
accepted (it resolves to base/etc/passwd, not an escape). No exception expected."""
|
|
result = PathValidator.validate_safe_path(
|
|
"a/b/../../c/file.txt", "/tmp"
|
|
)
|
|
assert result is not None
|
|
|
|
def test_valid_relative_path_accepted(self, tmp_path):
|
|
result = PathValidator.validate_safe_path(
|
|
"subdir/file.txt", str(tmp_path)
|
|
)
|
|
assert result is not None
|
|
assert "subdir/file.txt" in str(result)
|
|
|
|
def test_wrong_extension_raises(self, tmp_path):
|
|
with pytest.raises(ValueError, match="Invalid file type"):
|
|
PathValidator.validate_safe_path(
|
|
"config.txt",
|
|
str(tmp_path),
|
|
required_extensions=(".json", ".yaml"),
|
|
)
|
|
|
|
def test_correct_extension_accepted(self, tmp_path):
|
|
result = PathValidator.validate_safe_path(
|
|
"config.json",
|
|
str(tmp_path),
|
|
required_extensions=(".json", ".yaml"),
|
|
)
|
|
assert result.suffix == ".json"
|
|
|
|
def test_whitespace_stripped_from_input(self, tmp_path):
|
|
result = PathValidator.validate_safe_path(" file.txt ", str(tmp_path))
|
|
assert result is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# validate_local_filesystem_path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateLocalFilesystemPath:
|
|
def test_null_byte_raises(self):
|
|
with pytest.raises(ValueError, match="Null bytes"):
|
|
PathValidator.validate_local_filesystem_path("/tmp/file\x00.txt")
|
|
|
|
def test_control_characters_raise(self):
|
|
with pytest.raises(ValueError, match="Control characters"):
|
|
PathValidator.validate_local_filesystem_path("/tmp/file\x01.txt")
|
|
|
|
def test_double_dot_traversal_blocked(self):
|
|
with pytest.raises(ValueError, match="traversal"):
|
|
PathValidator.validate_local_filesystem_path("/tmp/../etc/passwd")
|
|
|
|
def test_tilde_expansion(self, tmp_path):
|
|
"""~ is expanded to home directory."""
|
|
with patch.object(Path, "home", return_value=tmp_path):
|
|
result = PathValidator.validate_local_filesystem_path("~/subdir")
|
|
assert str(tmp_path) in str(result)
|
|
|
|
def test_tilde_only_expansion(self, tmp_path):
|
|
"""~ alone expands to home directory."""
|
|
with patch.object(Path, "home", return_value=tmp_path):
|
|
result = PathValidator.validate_local_filesystem_path("~")
|
|
assert str(tmp_path) in str(result)
|
|
|
|
def test_restricted_etc_blocked(self):
|
|
with pytest.raises(ValueError, match="system directories"):
|
|
PathValidator.validate_local_filesystem_path("/etc/passwd")
|
|
|
|
def test_restricted_proc_blocked(self):
|
|
with pytest.raises(ValueError, match="system directories"):
|
|
PathValidator.validate_local_filesystem_path("/proc/cpuinfo")
|
|
|
|
def test_restricted_sys_blocked(self):
|
|
with pytest.raises(ValueError, match="system directories"):
|
|
PathValidator.validate_local_filesystem_path("/sys/kernel")
|
|
|
|
def test_restricted_dev_blocked(self):
|
|
with pytest.raises(ValueError, match="system directories"):
|
|
PathValidator.validate_local_filesystem_path("/dev/null")
|
|
|
|
def test_home_directory_allowed(self, tmp_path):
|
|
"""User home dir is not restricted."""
|
|
result = PathValidator.validate_local_filesystem_path(str(tmp_path))
|
|
assert result is not None
|
|
|
|
def test_empty_string_raises(self):
|
|
with pytest.raises(ValueError, match="Invalid path input"):
|
|
PathValidator.validate_local_filesystem_path("")
|
|
|
|
def test_none_raises(self):
|
|
with pytest.raises(ValueError, match="Invalid path input"):
|
|
PathValidator.validate_local_filesystem_path(None)
|
|
|
|
def test_relative_path_resolves(self, tmp_path):
|
|
"""Relative paths are resolved against cwd."""
|
|
with patch("os.getcwd", return_value=str(tmp_path)):
|
|
result = PathValidator.validate_local_filesystem_path("subdir")
|
|
assert result is not None
|
|
|
|
def test_custom_restricted_dirs(self, tmp_path):
|
|
"""Custom restricted_dirs are respected."""
|
|
restricted = tmp_path / "secret"
|
|
restricted.mkdir()
|
|
with pytest.raises(ValueError, match="system directories"):
|
|
PathValidator.validate_local_filesystem_path(
|
|
str(restricted), restricted_dirs=[restricted]
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# sanitize_for_filesystem_ops
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSanitizeForFilesystemOps:
|
|
def test_non_absolute_path_raises(self):
|
|
with pytest.raises(ValueError, match="must be absolute"):
|
|
PathValidator.sanitize_for_filesystem_ops(Path("relative/path"))
|
|
|
|
def test_absolute_path_returned(self, tmp_path):
|
|
result = PathValidator.sanitize_for_filesystem_ops(tmp_path)
|
|
assert result.is_absolute()
|
|
assert str(tmp_path) in str(result)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# validate_config_path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateConfigPath:
|
|
def test_null_byte_raises(self):
|
|
with pytest.raises(ValueError, match="Null bytes"):
|
|
PathValidator.validate_config_path("config\x00.json")
|
|
|
|
def test_empty_string_raises(self):
|
|
with pytest.raises(ValueError, match="Invalid config path input"):
|
|
PathValidator.validate_config_path("")
|
|
|
|
def test_none_raises(self):
|
|
with pytest.raises(ValueError, match="Invalid config path input"):
|
|
PathValidator.validate_config_path(None)
|
|
|
|
def test_double_dot_traversal_blocked(self):
|
|
with pytest.raises(ValueError, match="traversal"):
|
|
PathValidator.validate_config_path("../../etc/passwd.json")
|
|
|
|
def test_restricted_etc_prefix_blocked(self):
|
|
with pytest.raises(ValueError, match="restricted system directory"):
|
|
PathValidator.validate_config_path("/etc/shadow.json")
|
|
|
|
def test_restricted_proc_prefix_blocked(self):
|
|
with pytest.raises(ValueError, match="restricted system directory"):
|
|
PathValidator.validate_config_path("proc/something")
|
|
|
|
def test_absolute_path_invalid_extension_blocked(self):
|
|
"""Absolute path starting with / causes safe_join(/, path) -> None -> error."""
|
|
# safe_join("/", "/some/path") returns None for any absolute path
|
|
# so any absolute /path causes "Invalid absolute path" or a traversal error
|
|
with pytest.raises(ValueError):
|
|
PathValidator.validate_config_path("/some/config.exe")
|
|
|
|
def test_absolute_path_causes_safe_join_failure(self):
|
|
"""validate_config_path with /absolute/path raises ValueError since
|
|
safe_join('/','/'+ path) returns None (werkzeug treats it as traversal)."""
|
|
with pytest.raises(ValueError):
|
|
PathValidator.validate_config_path("/home/user/config.json")
|
|
|
|
def test_relative_path_with_valid_extension_accepted(self, tmp_path):
|
|
"""Relative config path with valid extension goes through validate_safe_path."""
|
|
# Create a json file inside tmp_path so the path is valid
|
|
config_file = tmp_path / "my_config.json"
|
|
config_file.write_text("{}")
|
|
result = PathValidator.validate_config_path(
|
|
"my_config.json", config_root=str(tmp_path)
|
|
)
|
|
assert result.suffix == ".json"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# validate_model_path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateModelPath:
|
|
def test_missing_model_file_raises(self, tmp_path):
|
|
with pytest.raises(ValueError, match="not found"):
|
|
PathValidator.validate_model_path(
|
|
"nonexistent.gguf", model_root=str(tmp_path)
|
|
)
|
|
|
|
def test_path_is_directory_not_file_raises(self, tmp_path):
|
|
subdir = tmp_path / "models"
|
|
subdir.mkdir()
|
|
with pytest.raises(ValueError, match="not a file|not found"):
|
|
PathValidator.validate_model_path(
|
|
"models", model_root=str(tmp_path)
|
|
)
|
|
|
|
def test_valid_model_file_accepted(self, tmp_path):
|
|
model = tmp_path / "model.gguf"
|
|
model.write_bytes(b"fake gguf data")
|
|
result = PathValidator.validate_model_path(
|
|
"model.gguf", model_root=str(tmp_path)
|
|
)
|
|
assert result == model
|
|
|
|
def test_path_traversal_in_model_path_blocked(self, tmp_path):
|
|
with pytest.raises(ValueError, match="traversal|Invalid path"):
|
|
PathValidator.validate_model_path(
|
|
"../etc/passwd", model_root=str(tmp_path)
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# validate_data_path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateDataPath:
|
|
def test_traversal_blocked(self, tmp_path):
|
|
with pytest.raises(ValueError, match="traversal|Invalid path"):
|
|
PathValidator.validate_data_path("../../secret", str(tmp_path))
|
|
|
|
def test_valid_data_path_accepted(self, tmp_path):
|
|
result = PathValidator.validate_data_path("data/file.db", str(tmp_path))
|
|
assert result is not None
|
|
assert "data/file.db" in str(result)
|
|
|
|
def test_null_byte_blocked(self, tmp_path):
|
|
with pytest.raises(ValueError, match="Null bytes|Invalid path"):
|
|
PathValidator.validate_data_path("file\x00.db", str(tmp_path))
|