PST-026: Rate-limit and lockout counters share a small database cache that culls by key order, so anyone can evict them #755

Closed
opened 2026-10-10 10:56:03 +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-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.


  • Severity: Low
  • Category: authentication / brute-force protection
  • Location: src/postulo/config/settings/base.py (179-191)
  • Found by: completeness review: chains
  • Related: PST-005 (#734)

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.

  1. Django's DatabaseCache defaults to MAX_ENTRIES=300 and CULL_FREQUENCY=3. Postulo passes no OPTIONS. Whenever more than 300 live rows exist, the next write deletes the lexicographically smallest third of the keys, not the oldest.
  2. allauth's counters are keyed ':1:allauth:rl:login_failed:<sha256>', so they sort before every ':1:postulo:rate:...' key. The victim's lock-out key is allauth:rl:login_failed:sha256('account:<pk>'), because Postulo's adapter resolves any typed name to 'account:<pk>'.
  3. Member route: create about 320 API tokens (creation is not rate-limited) and call the API once with each. Each first call creates a postulo:rate:api:<pk>:<window> row. The cache is now over 300, the next write culls the smallest third, and every allauth lock-out sorts below the api keys.
  4. Stranger route: send about 330 failed sign-ins with distinct usernames, spread over about 40 addresses and chosen so their key hashes sort above the victim's. The victim's key is then in the culled third.
  5. Guess five passwords, trigger a cull, and repeat. TOTP-replay marks (allauth uses cache.add) are evicted the same way.
    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.

base.py:189-190 `CACHES = {"default": env.cache_url("POSTULO_CACHE_URL", default=f"dbcache://{CACHE_TABLE}")}`. db.py `_cull`: `cull_num = remaining_num // self._cull_frequency` ... `DELETE FROM table WHERE cache_key < <the cull_num-th key>`. Scratch test output: 'correct password right after lock-out -> status 200 locked: True' then 'correct password after cache flood -> status 302'.

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.

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-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. --- - **Severity:** Low - **Category:** authentication / brute-force protection - **Location:** `src/postulo/config/settings/base.py` (179-191) - **Found by:** completeness review: chains - **Related:** PST-005 (#734) **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.** 1. Django's DatabaseCache defaults to MAX_ENTRIES=300 and CULL_FREQUENCY=3. Postulo passes no OPTIONS. Whenever more than 300 live rows exist, the next write deletes the lexicographically smallest third of the keys, not the oldest. 2. allauth's counters are keyed ':1:allauth:rl:login_failed:&lt;sha256>', so they sort before every ':1:postulo:rate:...' key. The victim's lock-out key is allauth:rl:login_failed:sha256('account:&lt;pk>'), because Postulo's adapter resolves any typed name to 'account:&lt;pk>'. 3. Member route: create about 320 API tokens (creation is not rate-limited) and call the API once with each. Each first call creates a postulo:rate:api:&lt;pk>:&lt;window> row. The cache is now over 300, the next write culls the smallest third, and every allauth lock-out sorts below the api keys. 4. Stranger route: send about 330 failed sign-ins with distinct usernames, spread over about 40 addresses and chosen so their key hashes sort above the victim's. The victim's key is then in the culled third. 5. Guess five passwords, trigger a cull, and repeat. TOTP-replay marks (allauth uses cache.add) are evicted the same way. 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.** ```text base.py:189-190 `CACHES = {"default": env.cache_url("POSTULO_CACHE_URL", default=f"dbcache://{CACHE_TABLE}")}`. db.py `_cull`: `cull_num = remaining_num // self._cull_frequency` ... `DELETE FROM table WHERE cache_key < <the cull_num-th key>`. Scratch test output: 'correct password right after lock-out -> status 200 locked: True' then 'correct password after cache flood -> status 302'. ``` **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.
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:34 +00:00
Author
Owner

Fixed in 1c8df70aa on main.

Fixed in 1c8df70aa 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#755
No description provided.