PST-046: The unattended media sweep has no floor: a pass that sees an empty or wrong database deletes every user's files #775
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#775
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found in the security audit of 2026-10-10 (
SECURITY-AUDIT-2026-10-10.md), run againstmainatc30851bfrom 8 to 10 October 2026, as PST-046. Severity: Low.The text below is the audit's entry, unchanged apart from links to the other issues it names. The finding was produced by automated review and checked by a second automated pass; re-check it before acting on it.
src/postulo/documents/scrub.py(97-121)Who can do it, and what they need. No external attacker; an operational hazard for the personal data the application promises to keep. Precondition: the scheduler runs (default
POSTULO_MEDIA_SWEEP=true) against a database that names no files while MEDIA_ROOT still holds them: a mis-set database URL after an upgrade (SQLite default path, fresh and migrated), a Postgres pointed at an empty schema, a restore that has replaced the database but not yet written media while the …How it works.
send_due_reminderscallsscrub.sweep()on every scheduler pass.sweep()iteratesmedia.unreferenced(older_than=now - 24h), which isfilestore.listing()minusreferenced_names();referenced_names()is simply every FileField value in every model of the current database.filestore.delete()d, with only a log line each; there is no check that the account table is non-empty, that the referenced set is non-trivial, that a restore is in progress, or a cap on how many files one pass may remove.Impact. Irreversible loss of every person's uploaded and rendered documents on an instance after one misconfiguration or a restore run without stopping the scheduler. Not a confidentiality issue, but the documents are the personal data the project exists to protect, and the operator's recovery is only a backup.
Evidence.
Verification (verdict: confirmed; severity after verification: Low). Keep as a low-severity data-safety hardening gap, not an attack. Files: src/postulo/documents/scrub.py:103-121 (sweep), src/postulo/core/media.py:41-55 (unreferenced), src/postulo/notifications/management/commands/send_due_reminders.py:218. Drop two stated preconditions: the restore-in-progress scenario (busy_reason refusal; database restored before media; restored files get a fresh mtime and so the 24h grace) and the plugin-removed scenario (no plugin owns a model or FileField; INSTALLED_APPS is static). Real preconditions are a database that names none or few of the files while the media volume is kept (mis-set or dropped POSTULO_DATABASE_URL outside the shipped compose files, a SQLite-to-Postgres switch without restore, a recreated schema), or
restore --forceof an older archive, where the closing message wrongly implies prune_media is the operator's decision. Suggested remediation: a floor that skips the sweep and logs loudly when the referenced set is empty or the account table is empty while the store is not, and/or a per-pass cap, and/or a default of list-only until opted in.Suggested remediation. Refuse to sweep when
referenced_names()is empty while the store is not, when no user exists, or when a restore lock/marker is present; cap removals per pass (e.g. 100) and skip the pass with a warning above it; move strays to a quarantine directory (trash/<date>/) that the next pass empties after a further grace, rather than unlinking at once; haverestore_backuptake the same lockbackups.take()uses so the scheduler stands down.Fixed in
fd217626don main.