SEC2-030: The catalogue's rollback protection rests on a cache entry that can be culled, and the official index signs no expiry #972

Closed
opened 2026-10-17 10:38:48 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-10-16 in a read-only security review of postulo at 56fc9d531, checked by a second, independent pass.

Severity: Informational · Category: Cryptography · Reference: CWE-294 (Authentication Bypass by Capture-replay); OWASP A08:2021 (Software and Data Integrity Failures)

Location:

  • src/postulo/plugins/catalogue.py:266-284 (refuse_stale: the last serial is kept_digests().get(name))
  • src/postulo/plugins/catalogue.py:320-358 (remember: cache.set(DIGESTS_CACHE_KEY, held, None) in the default cache)
  • src/postulo/config/settings/base.py:198-209 (default cache is DatabaseCache with MAX_ENTRIES, which culls a third of the keys when full)
  • catalogue/official/index.json ("serial": 1791801534, no "expires"); scripts/official_catalogue.py:145-150 (--expires-days defaults to 0)

Issue

The signature covers the index's bytes, not when they were served, so #783 added a serial that only goes up and an optional expires. The highest serial seen is remembered in the default cache under one key shared with the digests. That cache is Django's database cache, which culls keys when it passes MAX_ENTRIES (raised to 200 000 for exactly this class of problem, #755) and is emptied by anything that clears the cache; the key also goes with a cache backend change. Once it is gone, an older signed index, listing a release since withdrawn, is accepted again. The official index signs no expires, so there is no second check.

Impact

Whoever controls the hosting between an instance and the official catalogue (or the raw/branch/main path itself) can serve an older signed index after the instance has lost its memory of the serial. What that buys is a withdrawn release of a plugin the administrator then chooses to install, still checksum-matched to what the project once signed. Informational: it needs a withdrawn release worth re-offering and an administrator pressing Install.

Fix

Keep the highest serial per repository on the PluginRepository row (a last_serial column written by remember), which a cache cull cannot take away; a repository configured from the environment rather than a row needs a settings-backed place for its serial too. Publish the official index with --expires-days (a month or two) so a stale copy refuses itself. Done when: a test in tests/test_plugin_install.py clears the cache between two fetches and asserts the lower serial is still refused.

#783 (closed) - added the serial with the cache-based memory; this is the gap in that fix. #755 (closed) - the cache's MAX_ENTRIES.

Found on 2026-10-16 in a read-only security review of `postulo` at `56fc9d531`, checked by a second, independent pass. **Severity:** Informational · **Category:** Cryptography · **Reference:** CWE-294 (Authentication Bypass by Capture-replay); OWASP A08:2021 (Software and Data Integrity Failures) **Location:** - `src/postulo/plugins/catalogue.py:266-284` (`refuse_stale`: the last serial is `kept_digests().get(name)`) - `src/postulo/plugins/catalogue.py:320-358` (`remember`: `cache.set(DIGESTS_CACHE_KEY, held, None)` in the default cache) - `src/postulo/config/settings/base.py:198-209` (default cache is `DatabaseCache` with `MAX_ENTRIES`, which culls a third of the keys when full) - `catalogue/official/index.json` (`"serial": 1791801534`, no `"expires"`); `scripts/official_catalogue.py:145-150` (`--expires-days` defaults to 0) ## Issue The signature covers the index's bytes, not when they were served, so #783 added a `serial` that only goes up and an optional `expires`. The highest serial seen is remembered in the default cache under one key shared with the digests. That cache is Django's database cache, which culls keys when it passes `MAX_ENTRIES` (raised to 200 000 for exactly this class of problem, #755) and is emptied by anything that clears the cache; the key also goes with a cache backend change. Once it is gone, an older signed index, listing a release since withdrawn, is accepted again. The official index signs no `expires`, so there is no second check. ## Impact Whoever controls the hosting between an instance and the official catalogue (or the `raw/branch/main` path itself) can serve an older signed index after the instance has lost its memory of the serial. What that buys is a withdrawn release of a plugin the administrator then chooses to install, still checksum-matched to what the project once signed. Informational: it needs a withdrawn release worth re-offering and an administrator pressing Install. ## Fix Keep the highest serial per repository on the `PluginRepository` row (a `last_serial` column written by `remember`), which a cache cull cannot take away; a repository configured from the environment rather than a row needs a settings-backed place for its serial too. Publish the official index with `--expires-days` (a month or two) so a stale copy refuses itself. Done when: a test in `tests/test_plugin_install.py` clears the cache between two fetches and asserts the lower serial is still refused. ## Related #783 (closed) - added the serial with the cache-based memory; this is the gap in that fix. #755 (closed) - the cache's `MAX_ENTRIES`.
tiagoagueda added this to the 1.0.0 milestone 2026-10-17 10:38:49 +00:00
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#972
No description provided.