fix(v2): enforce repository protocol safety

This commit is contained in:
2026-07-27 21:10:26 +02:00
parent 175a550c7b
commit 908859084a
4 changed files with 76 additions and 19 deletions
+18 -6
View File
@@ -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 {
+55 -11
View File
@@ -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")
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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()