PST-026: Rate-limit and lockout counters share a small database cache that culls by key order, so anyone can evict them #755
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#755
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-026. 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.
src/postulo/config/settings/base.py(179-191)Who can do it, and what they need. (a) Any signed-in member who can mint API tokens. (b) An unauthenticated stranger with a few dozen source addresses (an IPv6 /64 or a small botnet), because allauth's per-IP limits cap each address at about 10 failed sign-ins per minute. Goal: guess a victim's password without the per-account lock-out (5 failures per 5 minutes), or reset Postulo's own counters.
How it works.
Verified end to end in the test suite:
(i) 5 wrong passwords, then even the correct password was refused (200, 'Too many failed login attempts').
(ii) After 320 ApiToken.issue() calls plus throttle.api(token) each, the correct password was accepted (302).
(iii) The same result from an unauthenticated flood of 330 junk sign-ins from 42 spoofed REMOTE_ADDRs.
Impact. The advertised controls 'sign-in rate-limited per address and per account' and the per-account anti-abuse limits can be reset on demand by any member, and by a modestly distributed stranger. A weak password on a targeted account can then be guessed at much more than the designed 1440 attempts a day. Natural load on a busy instance also evicts counters at random.
Evidence.
Verification (verdict: confirmed; severity after verification: Low; first-pass claim: Medium). Keep the file/line list: base.py:189-191, adapter.py:71-95, Django db.py:_base_set (113) and _cull (258), core/throttle.py (consume/_key), api/views.py ApiTokenCreateView (no cap on tokens per account), api/auth.py:_within_its_allowance. Corrections: (a) An IPv6 /64 is one address for allauth's rate limiting (RATE_LIMIT_IPV6_PREFIX=64), so the stranger needs roughly 6-8 distinct IPv4 addresses or /64s sustained for 5 minutes (10 failed sign-ins per address per minute, junk rows living 300 s), or more for a shorter burst. (b) The member route is valid but needs an invited account, since registration is closed by default. It works from one address. The guesses are still bounded by the 10 per minute per-address limit, so it is about 10 guesses a minute against the designed 1 a minute, plus about 80-100 extra token mints per round. (c) Natural eviction on a small instance is speculative: about 13 live rows were present in the test. (d) Impact is a bypass of the per-account lock-out, giving an increase of roughly 10-25x in guess rate. It is not unlimited guessing, because the per-address limit stays. …
Suggested remediation. Give rate limiting and lock-outs their own store: a dedicated cache alias with a large MAX_ENTRIES and CULL_FREQUENCY=0 semantics, a counter table with a TTL sweep, or Redis. At least set OPTIONS MAX_ENTRIES to a large value and document it. Cap API-token issuance per account. Add a start-up check that warns when the default dbcache is used.
Fixed in
1c8df70aaon main.