PST-005: Rate limits longer than five minutes collapse to five minutes on the default database cache #734
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#734
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-005. Severity: Medium.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/core/throttle.py(112-136)Who can do it, and what they need. Any signed-in member (or, for the /logs and /metrics wrong-token bar, any stranger) on a stock install. The stock install uses the database cache (POSTULO_CACHE_URL unset), whose incr() is Django's generic get-then-set, not an atomic increment. The attacker only needs to pause between bursts.
How it works.
Verified: with a real DatabaseCache, the stored expiry after the first consume was 3599 s away and after the second only 299 s away.
Impact. Every Postulo-owned limit longer than 5 minutes is really 'N per 5 minutes of idleness'. This weakens the anti-enumeration bound on telephone numbers, the outbound-fetch bound, the recovery-link mint limit, the per-number SMS bound (a stranger's phone can be texted about 12 times more often than documented) and the wrong-token lockout. The tests cannot see it: throttle._now() moves only the module's clock, and LocMem and Redis caches increment natively, so only the default database cache is affected.
Evidence.
Verification (verdict: confirmed; severity after verification: Medium).
The code path is unambiguous and the scratch test reproduces it. throttle.consume() creates the counter with cache.add(key, 1, timeout=rate.seconds) (throttle.py:125). Every later request in the window goes through cache.incr(key) (throttle.py:129). The stock install uses DatabaseCache, because POSTULO_CACHE_URL is unset and base.py:189-191 defaults to dbcache://postulo_cache. Neither docker/compose*.yml nor .env.example sets a cache. DatabaseCache does not override incr, so it inherits BaseCache.incr (Django 6.1.1). That does get, then self.set(key, new_value, version=version) with no timeout, so the row's expiry is reset to the 300 s default. The counter therefore lives only 5 minutes after its last use, not until the end of the window. Because the key is a fixed-window key and expires on its own, the next consume() in the same hour takes the cache.add branch and starts a fresh allowance. I looked for a mitigation and found none: there is no TIMEOUT override in CACHES, nothing touches the key, and the 'barred' and 'spent' readers only call cache.get. A client that keeps hammering while refused keeps extending the TTL and stays blocked, so the attacker has to pause, but pausing is trivial for a script. Redis, Memcached and LocMem increment natively and keep the TTL. FileBasedCache would share the bug. One correction to the report: the test settings (settings/test.py:61-65) also use DatabaseCache, so the suite does not miss the bug because of the cache backend. It misses it because the tests only move throttle._now() and never move the cache's own clock. The effect is a roughly 12x weakening of every limit longer than 5 minutes, not removal of the limit. The anti-enumeration bound on phone numbers is weakened (20/h becomes 240/h), as are the outbound-fetch bounds, per-number and per-account SMS limits, the recovery-link and connection-test limits, and the 10/h wrong-token bar on /logs and /metrics. The /metrics bar impact is negligible if the shared token has high entropy. Severity stays at medium, not high, because the limits still bind a burst, the SSRF guards still apply to every fetch, and the attacker must be signed in for everything except the /logs and /metrics token bar. The project's own docstring (throttle.py:20-24) discusses the non-atomic incr but not the expiry reset.
Corrections from verification. File and lines are correct: src/postulo/core/throttle.py:112-136, add at 125, incr at 129, ValueError fallback at 131. The test-suite explanation should read that the tests move only throttle._now() and not the cache's clock, since the test settings also use DatabaseCache. The caveat that the effect is about 12x, not removal, should be recorded. A suggested fix is to avoid BaseCache.incr on non-native caches by doing get then set with timeout equal to the remaining seconds of the window, or to follow the incr with cache.touch(key, remaining).
Verification evidence.
Suggested remediation. Do not rely on cache.incr() to preserve the TTL. Store {count, window_end} in the value and set it with timeout = remaining window time. Alternatively use cache.touch() after incr(), or use a dedicated counter table or an atomic backend. Add a test that runs against the real DatabaseCache and asserts the expiry after the second consume.