SEC2-032: Backup restore treats an archive whose signature was removed as "written before signing" and restores its database and media #974
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#974
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 on 2026-10-16 in a read-only security review of
postuloat56fc9d531, checked by a second, independent pass.Severity: Medium · Category: Cryptography · Reference: CWE-345 (Insufficient Verification of Data Authenticity); CWE-757 (Selection of Less-Secure Algorithm During Negotiation); OWASP A08:2021 (Software and Data Integrity Failures)
Location:
src/postulo/core/backup.py:713-722(authenticity: a manifest with noauthenticationkey isUNSIGNED)src/postulo/core/backup.py:800-810(restore_backuprefuses onlyUNVERIFIED)src/postulo/core/backup.py:851-855(an unsigned archive loses onlyplugins/)src/postulo/core/management/commands/restore.py:65-80(a warning line, exit status 0)tests/test_backup.py:547-563andtests/test_hardening_772.py:138-162(both pin the permissive behaviour)docs/THREAT-MODEL.md(row "Someone who obtains a backup", the sentence "Someone who can alter a copy of a backup is stopped by its signature (#772)")Issue
The signature added for #772 is only consulted when the manifest claims one.
authenticity()returnsUNSIGNEDthe moment the key is absent:and
restore_backuprefuses only theUNVERIFIEDverdict; for anything else that is notAUTHENTICit withholdsplugins/and nothing more:The database member and every
media/file are then written.verify_backupcompares the database member only with thesha256in the same manifest, which whoever alters the archive also writes. So altering a copy of a signed archive does not require forging the MAC: an archive without itsauthenticationobject is accepted with a warning saying it "was written before archives were signed". Nothing distinguishes a genuine pre-#772 archive from one whose signature was removed, becauseBACKUP_FORMATstayed at 2 when signing arrived.tests/test_backup.py:547-563(test_an_archive_from_before_the_plugins_went_in_still_restores) andtests/test_hardening_772.py:138-162both restore such an archive withoutunverified=True.The threat model promises more than the code does ("a restore refuses an archive that does not check out ... unless
--unverified"), while the wiki page Backups and your data (line 317) already says an archive taken before signing needs--unverified: the documentation is ahead of the code. The Restore page (BackupRestoreView) shows the checksum result as "verified" without computingauthenticity(), and suggests a restore command without--unverified.Impact
Someone with write access to where backups are copied (an off-site share, a cloud bucket, a workstation the archive passes through) who waits for the operator to run
manage.py restoregets their altered database restored: an added administrator row or a replaced password hash is a full takeover of the restored instance and every account's documents on it; replaced media files are served to their owners as their own CVs and letters. The restore prints a warning but exits successfully, so a scripted disaster-recovery run never stops. Plugins (code run at start-up) are withheld, which is the one half of #772 that does hold.Fix
Treat an unsigned archive like an unverified one: a restore requires the operator's explicit
--unverifiedfor both, so an archive from before signing is restored once, knowingly, and an altered one is never restored by accident.On the Restore page, have
BackupRestoreViewcomputeauthenticity()(it already opens the archive to read the manifest), show the verdict (authentic/unsigned/unverified) beside the checksum result, and print--unverifiedin the suggested commands when the verdict is notauthentic. Update the threat-model sentence to match. As defence in depth, and only together with the first change, bumpBACKUP_FORMATto 3 and refuse a format-3 manifest withoutauthenticationoutright.Done when:
tests/test_backup.pytakes a fresh archive, rewrites the manifest withoutauthentication, and assertsrestore_backup(old)raisesBackupErrorwhilerestore_backup(old, unverified=True)restores and reportsauthenticity == UNSIGNED; the existing tests attests/test_backup.py:547andtests/test_hardening_772.py:138passunverified=True.Related
#772 (closed) - the signature was added for it, but its text and the threat model describe the unsigned path as a pre-signing archive restored without
plugins/; neither names an archive whose signature was removed, so the fix is incomplete on this point. #811 (closed) - the key mark, unrelated. #234 (closed) - the backup documentation.