Be strict about XDG_CACHE_DIR ownership etc
This commit is contained in:
parent
4277e029d0
commit
efb6d7cc15
2 changed files with 53 additions and 9 deletions
|
|
@ -8,6 +8,8 @@ from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from .harvest_safety import OutputSafetyError, ensure_private_dir
|
||||||
|
|
||||||
|
|
||||||
def _safe_component(s: str) -> str:
|
def _safe_component(s: str) -> str:
|
||||||
s = s.strip()
|
s = s.strip()
|
||||||
|
|
@ -44,16 +46,17 @@ class HarvestCache:
|
||||||
|
|
||||||
|
|
||||||
def _ensure_dir_secure(path: Path) -> None:
|
def _ensure_dir_secure(path: Path) -> None:
|
||||||
"""Create a directory with restrictive permissions; refuse symlinks."""
|
"""Create a private cache directory with output-path safety checks.
|
||||||
# Refuse a symlink at the leaf.
|
|
||||||
if path.exists() and path.is_symlink():
|
Cache roots are persistent, so existing directories are allowed, but they
|
||||||
raise RuntimeError(f"Refusing to use symlink path: {path}")
|
still need the same symlink-component and root-parent trust checks as
|
||||||
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
plaintext harvest/manifest output paths.
|
||||||
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
os.chmod(path, 0o700)
|
ensure_private_dir(path, label="cache directory")
|
||||||
except OSError:
|
except OutputSafetyError as e:
|
||||||
# Best-effort; on some FS types chmod may fail.
|
raise RuntimeError(str(e)) from e
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def new_harvest_cache_dir(*, hint: Optional[str] = None) -> HarvestCache:
|
def new_harvest_cache_dir(*, hint: Optional[str] = None) -> HarvestCache:
|
||||||
|
|
|
||||||
|
|
@ -95,3 +95,44 @@ def test_enroll_cache_dir_uses_default_when_xdg_not_set(monkeypatch):
|
||||||
monkeypatch.delenv("XDG_CACHE_HOME", raising=False)
|
monkeypatch.delenv("XDG_CACHE_HOME", raising=False)
|
||||||
result = enroll_cache_dir()
|
result = enroll_cache_dir()
|
||||||
assert str(result).endswith("/.local/cache/enroll")
|
assert str(result).endswith("/.local/cache/enroll")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_dir_secure_refuses_symlink_parent(tmp_path: Path):
|
||||||
|
from enroll.cache import _ensure_dir_secure
|
||||||
|
|
||||||
|
target = tmp_path / "target"
|
||||||
|
target.mkdir()
|
||||||
|
link = tmp_path / "link"
|
||||||
|
link.symlink_to(target, target_is_directory=True)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="symlink"):
|
||||||
|
_ensure_dir_secure(link / "enroll" / "harvest")
|
||||||
|
|
||||||
|
assert not (target / "enroll" / "harvest").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_dir_secure_rejects_unsafe_root_parent(tmp_path: Path, monkeypatch):
|
||||||
|
from enroll.cache import _ensure_dir_secure
|
||||||
|
import enroll.harvest_safety as hs
|
||||||
|
|
||||||
|
untrusted = tmp_path / "untrusted"
|
||||||
|
untrusted.mkdir()
|
||||||
|
untrusted.chmod(0o777)
|
||||||
|
|
||||||
|
monkeypatch.setattr(hs, "_effective_uid", lambda: 0)
|
||||||
|
with pytest.raises(RuntimeError, match="not owned by root|writable by group/other"):
|
||||||
|
_ensure_dir_secure(untrusted / "cache")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_dir_secure_rejects_existing_file_when_not_root(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
):
|
||||||
|
from enroll.cache import _ensure_dir_secure
|
||||||
|
import enroll.harvest_safety as hs
|
||||||
|
|
||||||
|
path = tmp_path / "cache"
|
||||||
|
path.write_text("not a dir", encoding="utf-8")
|
||||||
|
|
||||||
|
monkeypatch.setattr(hs, "_effective_uid", lambda: 1000)
|
||||||
|
with pytest.raises(RuntimeError, match="not a directory"):
|
||||||
|
_ensure_dir_secure(path)
|
||||||
|
|
|
||||||
Reference in a new issue