Files
Donkie 404b03173b security: stop backup rotation from churning away the restore history
Rotating deletes the oldest restore point and shifts the rest down, so enough
calls in quick succession leave nothing but snapshots of whatever state the
database is in now. POST /backup takes no parameters, so a bodyless cross-origin
form post reached it; the audit destroyed a full six-deep history that way
(todo.md task 2).

The origin guard from the previous commit already refuses that request, but the
rotation itself should not be this easy to abuse, so two further changes:

Rotation is skipped when the database has not actually changed. Not by comparing
the live database file, which the audit suggested: under WAL the live file does
not yet contain recent commits, so comparing it would skip backups that were
genuinely needed. Instead the snapshot is written to a pending file first and
compared against the newest backup -- sqlite's backup API reads through the
connection, so it sees WAL content. Identical means the pending file is
discarded and the history is left alone. This is fail-safe: if the comparison
ever came out unequal for an unchanged database, the result is the old behaviour
of rotating, never a missed backup.

Rotation is also rate-limited to one per five minutes, returning the newest
existing backup instead. Never applied when there is no backup to fall back on.

BackupResponse gained a `created` field so a client can tell the difference.
Additive, so v1-compatible, but load-bearing: telling a user they have a fresh
restore point when they do not is exactly the wrong failure mode here.

Verified against a real server -- six rapid calls now leave exactly one backup
and never rotate.
2026-07-27 19:33:07 +02:00

39 lines
1.2 KiB
Python

"""Integration tests for the backup endpoint."""
import httpx
from .conftest import URL, DbType, get_db_type
def test_backup():
"""Test triggering an automatic database backup."""
if get_db_type() != DbType.SQLITE:
return
# Trigger backup
result = httpx.post(f"{URL}/api/v1/backup")
result.raise_for_status()
def test_repeated_backups_do_not_churn_the_history():
"""Rotation discards the oldest restore point, so repeated calls must not each rotate.
The endpoint takes no parameters, so it used to be reachable by a bodyless cross-origin form
post; enough of those left nothing in the backup folder but snapshots of the damage.
"""
if get_db_type() != DbType.SQLITE:
return
results = []
for _ in range(6):
result = httpx.post(f"{URL}/api/v1/backup")
result.raise_for_status()
results.append(result.json())
# Every call still answers with a usable backup path.
assert all(entry["path"] for entry in results)
# But at most the first can have actually rotated: the database does not change in between,
# and the rate limit covers the case where something else changes it concurrently.
assert sum(entry["created"] for entry in results) <= 1