SEC2-030: The catalogue's rollback protection rests on a cache entry that can be culled, and the official index signs no expiry #972
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#972
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: 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 iskept_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 isDatabaseCachewithMAX_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-daysdefaults to 0)Issue
The signature covers the index's bytes, not when they were served, so #783 added a
serialthat only goes up and an optionalexpires. 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 passesMAX_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 noexpires, so there is no second check.Impact
Whoever controls the hosting between an instance and the official catalogue (or the
raw/branch/mainpath 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
PluginRepositoryrow (alast_serialcolumn written byremember), 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 intests/test_plugin_install.pyclears 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.