fix(v2): enforce repository protocol safety
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user