From 908859084ae97b1d6268ab3893c5051c09b91ea9 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Mon, 27 Jul 2026 21:10:26 +0200 Subject: [PATCH] fix(v2): enforce repository protocol safety --- backend/src/backup_tool/api/app.py | 24 ++++++-- backend/src/backup_tool/repository.py | 66 +++++++++++++++++---- tests/integration/test_repository_safety.py | 2 +- tests/security/test_session_security.py | 3 +- 4 files changed, 76 insertions(+), 19 deletions(-) diff --git a/backend/src/backup_tool/api/app.py b/backend/src/backup_tool/api/app.py index f8697b5..1553bb9 100644 --- a/backend/src/backup_tool/api/app.py +++ b/backend/src/backup_tool/api/app.py @@ -26,7 +26,12 @@ from backup_tool.db.models import ( Session, User, ) -from backup_tool.repository import RepositoryError, initialize, inspect_repository +from backup_tool.repository import ( + RepositoryError, + initialize, + inspect_repository, + remove_repository, +) from backup_tool.security.auth import ( hash_password, hash_token, @@ -529,10 +534,17 @@ def create_app(settings: Settings) -> FastAPI: compression=initialized.compression, encryption=initialized.encryption, ) - db.add(repository) - await db.flush() - await audit(db, request, "create", "repository", repository.id, "success", user.id) - await db.commit() + try: + db.add(repository) + await db.flush() + await audit(db, request, "create", "repository", repository.id, "success", user.id) + await db.commit() + except Exception as error: + await db.rollback() + remove_repository(initialized.root) + raise Problem( + 409, "repository_create_failed", "Repository metadata could not be stored." + ) from error return { "id": repository.id, "name": repository.name, @@ -589,7 +601,7 @@ def create_app(settings: Settings) -> FastAPI: if repository is None: raise Problem(404, "resource_not_found", "Repository was not found.") try: - inspected = inspect_repository(Path(repository.root)) + inspected = inspect_repository(settings, Path(repository.root)) except RepositoryError as error: raise Problem(409, "repository_invalid", str(error)) from error return { diff --git a/backend/src/backup_tool/repository.py b/backend/src/backup_tool/repository.py index 42bd935..ca39a32 100644 --- a/backend/src/backup_tool/repository.py +++ b/backend/src/backup_tool/repository.py @@ -5,9 +5,11 @@ import json import os import shutil from dataclasses import dataclass +from datetime import UTC, datetime from pathlib import Path from backup_tool.config import Settings +from backup_tool.ids import new_uuid7 class RepositoryError(ValueError): @@ -38,7 +40,14 @@ def _contained(root: Path, relative_path: str) -> Path: def _canonical_payload(compression: str, encryption: str) -> dict[str, object]: if compression != "none" or encryption != "none": raise RepositoryError("requested repository policy is unavailable") - return {"compression": compression, "encryption": encryption, "format_version": 1} + return { + "repository_id": str(new_uuid7()), + "format_version": 1, + "digest_algorithm": "sha256", + "compression": compression, + "encryption": {"mode": "none", "key_id": None}, + "created_at": datetime.now(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z"), + } def _canonical_json(payload: dict[str, object]) -> str: @@ -76,8 +85,24 @@ def initialize( return InitializedRepository(root=root, compression=compression, encryption=encryption) -def inspect_repository(root: Path) -> InitializedRepository: - metadata = root / "repository.json" +def remove_repository(root: Path) -> None: + try: + shutil.rmtree(root) + except FileNotFoundError: + return + + +def inspect_repository(settings: Settings, root: Path) -> InitializedRepository: + try: + resolved_root = root.resolve(strict=True) + except OSError as error: + raise RepositoryError("repository root is unreadable") from error + if root.is_symlink() or not any( + resolved_root != allowed.resolve() and allowed.resolve() in resolved_root.parents + for allowed in settings.repository_roots + ): + raise RepositoryError("repository root escapes configured roots") + metadata = resolved_root / "repository.json" try: raw = metadata.read_text(encoding="utf-8") payload = json.loads(raw) @@ -85,12 +110,31 @@ def inspect_repository(root: Path) -> InitializedRepository: raise RepositoryError("repository metadata is unreadable") from error if not isinstance(payload, dict): raise RepositoryError("repository metadata is invalid") - try: - expected = _canonical_json( - _canonical_payload(payload["compression"], payload["encryption"]) - ) - except (KeyError, TypeError, RepositoryError) as error: - raise RepositoryError("repository metadata is invalid") from error - if payload.get("format_version") != 1 or raw != expected: + required = { + "repository_id", + "format_version", + "digest_algorithm", + "compression", + "encryption", + "created_at", + } + if ( + set(payload) != required + or payload.get("format_version") != 1 + or payload.get("digest_algorithm") != "sha256" + ): raise RepositoryError("repository metadata is not canonical") - return InitializedRepository(root=root, compression="none", encryption="none") + encryption = payload.get("encryption") + if payload.get("compression") != "none" or encryption != {"mode": "none", "key_id": None}: + raise RepositoryError("repository metadata is invalid") + try: + from uuid import UUID + + UUID(str(payload["repository_id"])) + created_at = str(payload["created_at"]) + if not created_at.endswith("Z"): + raise ValueError + datetime.fromisoformat(created_at[:-1] + "+00:00") + except (ValueError, TypeError) as error: + raise RepositoryError("repository metadata is invalid") from error + return InitializedRepository(root=resolved_root, compression="none", encryption="none") diff --git a/tests/integration/test_repository_safety.py b/tests/integration/test_repository_safety.py index b227b28..21f67c9 100644 --- a/tests/integration/test_repository_safety.py +++ b/tests/integration/test_repository_safety.py @@ -45,7 +45,7 @@ def test_repository_inspection_rejects_noncanonical_metadata(tmp_path: Path) -> '{"encryption":"none","compression":"none","format_version":1}\n' ) with pytest.raises(RepositoryError, match="canonical"): - inspect_repository(result.root) + inspect_repository(settings, result.root) @pytest.mark.asyncio diff --git a/tests/security/test_session_security.py b/tests/security/test_session_security.py index 7574657..48f1d60 100644 --- a/tests/security/test_session_security.py +++ b/tests/security/test_session_security.py @@ -148,7 +148,8 @@ async def test_logout_revocation_survives_app_restart(tmp_path) -> None: transport=ASGITransport(restarted), base_url=settings.public_base_url ) as client: rejected = await client.get( - "/api/v2/auth/session", headers={"Cookie": f"backup_tool_session={copied_cookie}"} + "/api/v2/auth/session", + headers={"Cookie": f"backup_tool_session={copied_cookie}"}, ) assert rejected.status_code == 401 await restarted.state.engine.dispose()