PST-043: Backup archives are only self-consistent: nothing authenticates an archive, and a restore writes the plugins directory #772

Closed
opened 2026-10-10 10:56:16 +00:00 by tiagoagueda · 1 comment
Owner

Found in the security audit of 2026-10-10 (SECURITY-AUDIT-2026-10-10.md), run against main at c30851b from 8 to 10 October 2026, as PST-043. 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.


  • Severity: Low
  • Category: integrity / supply-chain
  • Location: src/postulo/core/backup.py (440-467,543-560,620-738; 533-540,549-560); src/postulo/core/views_backups.py (229-280; 229-281,283-327)
  • Found by: administrator surface and plugins, file handling, injection and parsers, secrets and cryptography
  • Related: PST-055 (#784), PST-082 (#811), PST-091 (#820)

Who can do it, and what they need. Someone who can alter a backup archive before an administrator restores it: write access to the off-site/cloud location the operator copies archives to, a compromised workstation the admin uploads from (Server settings -> Backups -> Upload at views_backups.py:229-280), or a MITM on an unencrypted copy. Precondition: an administrator later restores that archive (the documented disaster-recovery path).

How it works.

  1. Unpack the .tar.gz.
  2. Edit database.sqlite3 (add an is_superuser row, or replace a password hash) and/or drop a package directory with a postulo.* entry point - or any .pth/.py - under plugins/, and edit plugins/.plugins.json to list it.
  3. Recompute sha256 of the database member and write it into manifest.json; repack.
  4. verify_backup() compares the member against the manifest it just read from the same archive and passes; restore_backup() loads the database, writes every plugins/ member to POSTULO_PLUGINS_DIR (only path-escape checks are made), and runs migrate.
  5. At the next start the plugins directory is on the import path (installing.py:8-9) and plugins sync/registry loads the planted package: arbitrary code in the container, plus a silently inserted administrator account.

Impact. An operator cannot tell a genuine archive from a tampered one; a tampered restore yields RCE in the container and full data access. The threat model considers a backup only as something that can be read, not modified.

Evidence.

backup.py:449: `"sha256": _sha256(dump),` inside the manifest that travels in the same archive; backup.py:556-557: `if _hash_member(archive, member) != manifest["database"]["sha256"]: raise BackupError(...)` - the only integrity check; backup.py:711-728 write media/ and plugins/ members with no digest at all; backup.py:731 `call_command("migrate", ...)`; installing.py:8-9: 'plugins live on the data volume ... in a directory that is added to the import path at startup'.

Verification (verdict: confirmed; severity after verification: Low). Attack path correction: the Server settings -> Backups -> Upload route (views_backups.py:229-280) is an administrator's act, and an administrator is already trusted with the instance (they install plugins), so that route gains nothing beyond existing trust; the real path is the command-line manage.py restore of a copy that somebody with write access to the backup store (or a MITM on an unencrypted copy) altered. Impact correction: no entry point or plugins sync is needed; a .pth under plugins/ executes at every process start via site.addsitedir in PluginsConfig.ready() (plugins/apps.py:28), and plugins sync at the entrypoint runs activate() too. Remediation: sign the manifest (and have the manifest carry digests of every member) with an HMAC under POSTULO_FIELD_KEY or a dedicated backup key, refuse a plugins/ member on restore unless its package matches the sha256 the plugins record carries, and have restore print a loud notice when an archive is unsigned; or at minimum state in the threat model and the restore page that an archive is trusted as written and must be stored where nobody else can write.

Suggested remediation. Add a MAC to the manifest: HMAC-SHA256 over a canonical manifest (which should list a digest for every member, not only the database) under a key derived from the field key with its own prefix (postulo-backup-mac:). A restore then reports 'authentic' when the key matches (the case the fingerprint already detects) and refuses, or demands --unverified, otherwise. Optionally support an Ed25519 signing key for archives shipped off-site. Document that restoring an archive is equivalent to running its plugins/ contents.

Also covered by this issue.

  • Backup upload verifies only self-consistency, needs no reauthentication, and reads the manifest without a size cap (src/postulo/core/views_backups.py (229-280); Informational). No direct compromise: restore is a deliberate operator action and administrators already install plugins. The gaps are consistency with the page's own rule that sensitive backup actions need a fresh authentication, a worker-OOM on a crafted upload (denial of service is out of scope by the threat …
  • Backup upload/restore chain: the manifest is never reconciled with the archive's contents, the restore page describes an uploaded archive from its manifest alone, a restored plugins/ directory is executable, and the manifest member is read whole into memory (src/postulo/core/views_backups.py (229-281,283-327); Informational). Restore of an archive another administrator uploaded is code execution and account injection with no indication on the page (the threat model trusts backups but says nothing about uploaded ones); the unbounded manifest read is a DoS lever for an administrator (out of scope as DoS, noted as …
  • Backup upload verification decompresses and hashes an unbounded database member before accepting the archive (src/postulo/core/backup.py (533-540,549-560); Informational). CPU/worker time on an administrator action; denial of service is out of scope and the actor is already trusted, so this is an observation only.
Found in the security audit of 2026-10-10 (`SECURITY-AUDIT-2026-10-10.md`), run against `main` at `c30851b` from 8 to 10 October 2026, as **PST-043**. 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. --- - **Severity:** Low - **Category:** integrity / supply-chain - **Location:** `src/postulo/core/backup.py` (440-467,543-560,620-738; 533-540,549-560); `src/postulo/core/views_backups.py` (229-280; 229-281,283-327) - **Found by:** administrator surface and plugins, file handling, injection and parsers, secrets and cryptography - **Related:** PST-055 (#784), PST-082 (#811), PST-091 (#820) **Who can do it, and what they need.** Someone who can alter a backup archive before an administrator restores it: write access to the off-site/cloud location the operator copies archives to, a compromised workstation the admin uploads from (Server settings -> Backups -> Upload at views_backups.py:229-280), or a MITM on an unencrypted copy. Precondition: an administrator later restores that archive (the documented disaster-recovery path). **How it works.** 1. Unpack the .tar.gz. 2. Edit database.sqlite3 (add an is_superuser row, or replace a password hash) and/or drop a package directory with a `postulo.*` entry point - or any .pth/.py - under plugins/, and edit plugins/.plugins.json to list it. 3. Recompute `sha256` of the database member and write it into manifest.json; repack. 4. verify_backup() compares the member against the manifest it just read from the same archive and passes; restore_backup() loads the database, writes every plugins/ member to POSTULO_PLUGINS_DIR (only path-escape checks are made), and runs migrate. 5. At the next start the plugins directory is on the import path (installing.py:8-9) and `plugins sync`/registry loads the planted package: arbitrary code in the container, plus a silently inserted administrator account. **Impact.** An operator cannot tell a genuine archive from a tampered one; a tampered restore yields RCE in the container and full data access. The threat model considers a backup only as something that can be read, not modified. **Evidence.** ```text backup.py:449: `"sha256": _sha256(dump),` inside the manifest that travels in the same archive; backup.py:556-557: `if _hash_member(archive, member) != manifest["database"]["sha256"]: raise BackupError(...)` - the only integrity check; backup.py:711-728 write media/ and plugins/ members with no digest at all; backup.py:731 `call_command("migrate", ...)`; installing.py:8-9: 'plugins live on the data volume ... in a directory that is added to the import path at startup'. ``` **Verification** (verdict: confirmed; severity after verification: Low). Attack path correction: the Server settings -> Backups -> Upload route (views_backups.py:229-280) is an administrator's act, and an administrator is already trusted with the instance (they install plugins), so that route gains nothing beyond existing trust; the real path is the command-line `manage.py restore` of a copy that somebody with write access to the backup store (or a MITM on an unencrypted copy) altered. Impact correction: no entry point or `plugins sync` is needed; a .pth under plugins/ executes at every process start via site.addsitedir in PluginsConfig.ready() (plugins/apps.py:28), and `plugins sync` at the entrypoint runs activate() too. Remediation: sign the manifest (and have the manifest carry digests of every member) with an HMAC under POSTULO_FIELD_KEY or a dedicated backup key, refuse a plugins/ member on restore unless its package matches the sha256 the plugins record carries, and have `restore` print a loud notice when an archive is unsigned; or at minimum state in the threat model and the restore page that an archive is trusted as written and must be stored where nobody else can write. **Suggested remediation.** Add a MAC to the manifest: HMAC-SHA256 over a canonical manifest (which should list a digest for every member, not only the database) under a key derived from the field key with its own prefix (`postulo-backup-mac:`). A restore then reports 'authentic' when the key matches (the case the fingerprint already detects) and refuses, or demands --unverified, otherwise. Optionally support an Ed25519 signing key for archives shipped off-site. Document that restoring an archive is equivalent to running its plugins/ contents. **Also covered by this issue.** - **Backup upload verifies only self-consistency, needs no reauthentication, and reads the manifest without a size cap** (`src/postulo/core/views_backups.py` (229-280); Informational). No direct compromise: restore is a deliberate operator action and administrators already install plugins. The gaps are consistency with the page's own rule that sensitive backup actions need a fresh authentication, a worker-OOM on a crafted upload (denial of service is out of scope by the threat … - **Backup upload/restore chain: the manifest is never reconciled with the archive's contents, the restore page describes an uploaded archive from its manifest alone, a restored plugins/ directory is executable, and the manifest member is read whole into memory** (`src/postulo/core/views_backups.py` (229-281,283-327); Informational). Restore of an archive another administrator uploaded is code execution and account injection with no indication on the page (the threat model trusts backups but says nothing about uploaded ones); the unbounded manifest read is a DoS lever for an administrator (out of scope as DoS, noted as … - **Backup upload verification decompresses and hashes an unbounded database member before accepting the archive** (`src/postulo/core/backup.py` (533-540,549-560); Informational). CPU/worker time on an administrator action; denial of service is out of scope and the actor is already trusted, so this is an observation only.
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:47 +00:00
Author
Owner

Fixed in b80f825c1 on main.

Fixed in b80f825c1 on main.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#772
No description provided.