PST-027: Rate-limit counters live inside the request transaction, so a charge made before an exception is rolled back #756
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#756
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-027. 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/core/throttle.py(112-136)Who can do it, and what they need. An authenticated account or API token holder who wants more of a bounded action than the allowance. Precondition: a view that runs under ATOMIC_REQUESTS, calls throttle.consume() and can then be made to raise an exception that escapes the view (Http404, PermissionDenied, an unhandled error). …
How it works.
Impact. A per-account or per-address limit stops counting for any request shape that raises after being charged. Today that is only the undercounting of failed requests; a future view that looks up a record after spending a limit (the recovery-link view already does the 404 lookup before consume, which is why it is safe) would hand an attacker an unbounded allowance. Same mechanism weakens the /logs and /metrics wrong-token counter if a refusal ever becomes an exception instead of a returned 401.
Evidence.
Verification (verdict: confirmed; severity after verification: Low). Attacker/precondition as claimed. Correction to the call-site list: the outbox charge at correspondence.py:131 runs under ATOMIC_REQUESTS from SendDocumentsView (documents/views.py:934), and the API per-token charge at api/auth.py:29 runs inside the transaction on every API route except the three in OUTSIDE_A_TRANSACTION; neither is exploitable because their refusals are returned, not raised (ninja handlers at api/problems.py:203-253). Today the only thing that un-charges is an unhandled 500 or an unhandled SuspiciousOperation subclass (TooManyFieldsSent and the like) after the charge, which gains nothing. Remediation (describe only): make the counter independent of the request transaction — e.g. give the throttle its own cache alias on a second connection to the same database (a DATABASES alias routed for CacheEntry) or recommend a non-database cache for counting in the docs/check; or at minimum state in throttle.py's docstring and in tests/security/test_rate_limits.py that a charge must be followed by a return, never a raise, so a future view (one that looks up a record after spending the allowance) cannot reopen this.
Suggested remediation. Spend counters outside the request transaction: route the cache table to a second database alias of the same database through a router so the DB cache uses its own connection, or recommend Redis in production and make the dbcache default fail-safe by running consume() under transaction.atomic(using=...) on that alias; alternatively mark every view that spends an allowance with non_atomic_requests and add a security test asserting that a charge survives a view that raises Http404.
Fixed in
a5d763a92on main.