PST-070: API throttling gaps: invalid tokens are not throttled, scope is checked before the allowance is charged, and tokens multiply the allowance #799

Closed
opened 2026-10-10 10:56:40 +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-070. Severity: Informational.

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: Informational
  • Category: rate-limiting consistency
  • Location: src/postulo/api/auth.py (45-56; 102-133; 22-38)
  • Found by: business logic and imports, ownership review of api, secrets and cryptography

Who can do it, and what they need. A stranger on the internet. No precondition.

How it works.

  1. Every request with a wrong bearer costs one indexed SELECT on api_apitoken (lookup hashes the token and queries) and answers 401; nothing counts failures per address.
  2. throttle.api() is only applied after a token is found, so it bounds valid tokens, not guessing.
  3. By contrast /logs and /metrics call throttle.barred/refused and lock an address out after ten wrong tokens an hour (#472).
  4. Tokens are 32 random bytes (secrets.token_urlsafe) hashed with SHA-256, so guessing is computationally infeasible; the only effect is unbounded cheap 401s, which the threat model places out of scope (denial of service belongs to the proxy).

Impact. No credential risk given the token entropy; an inconsistency in the project's own hardening pattern rather than a vulnerability.

Verification (verdict: confirmed; severity after verification: Informational). Keep as informational. Line references: lookup is auth.py:42-52 (not 45-56); the per-address wrong-token pattern is throttle.py:186-207. Note token_hash is unique (indexed), so each wrong guess is one indexed SELECT. No exploitable effect beyond cheap 401s, which are out of scope per THREAT-MODEL.md line 136.

Suggested remediation. If wanted, apply throttle.barred/refused per REMOTE_ADDR around lookup() failures in TokenAuth/ScopedAuth/_no_such_address/for_readers_of_the_api, as /logs does; otherwise record in the threat model that the API relies on token entropy and the reverse proxy for this.

Also covered by this issue.

  • Scope is checked before the per-token allowance is charged, so a token lacking a scope can probe endpoints without spending its rate (src/postulo/api/auth.py (102-133); Informational). Unbounded cheap probing (one indexed query each) and the ability to enumerate which scopes a token lacks without consuming allowance; no data exposure.
  • API allowance is per token and token issuance is unbounded, so one account can multiply its API rate at will (src/postulo/api/auth.py (22-38); Informational). The per-account bounds that matter (capture fetches 30/h, phone-number collision answers 20/h) are keyed on the account and hold. …
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-070**. Severity: **Informational**. 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:** Informational - **Category:** rate-limiting consistency - **Location:** `src/postulo/api/auth.py` (45-56; 102-133; 22-38) - **Found by:** business logic and imports, ownership review of api, secrets and cryptography **Who can do it, and what they need.** A stranger on the internet. No precondition. **How it works.** 1. Every request with a wrong bearer costs one indexed SELECT on api_apitoken (lookup hashes the token and queries) and answers 401; nothing counts failures per address. 2. `throttle.api()` is only applied after a token is found, so it bounds valid tokens, not guessing. 3. By contrast `/logs` and `/metrics` call throttle.barred/refused and lock an address out after ten wrong tokens an hour (#472). 4. Tokens are 32 random bytes (secrets.token_urlsafe) hashed with SHA-256, so guessing is computationally infeasible; the only effect is unbounded cheap 401s, which the threat model places out of scope (denial of service belongs to the proxy). **Impact.** No credential risk given the token entropy; an inconsistency in the project's own hardening pattern rather than a vulnerability. **Verification** (verdict: confirmed; severity after verification: Informational). Keep as informational. Line references: lookup is auth.py:42-52 (not 45-56); the per-address wrong-token pattern is throttle.py:186-207. Note token_hash is unique (indexed), so each wrong guess is one indexed SELECT. No exploitable effect beyond cheap 401s, which are out of scope per THREAT-MODEL.md line 136. **Suggested remediation.** If wanted, apply throttle.barred/refused per REMOTE_ADDR around `lookup()` failures in TokenAuth/ScopedAuth/_no_such_address/for_readers_of_the_api, as /logs does; otherwise record in the threat model that the API relies on token entropy and the reverse proxy for this. **Also covered by this issue.** - **Scope is checked before the per-token allowance is charged, so a token lacking a scope can probe endpoints without spending its rate** (`src/postulo/api/auth.py` (102-133); Informational). Unbounded cheap probing (one indexed query each) and the ability to enumerate which scopes a token lacks without consuming allowance; no data exposure. - **API allowance is per token and token issuance is unbounded, so one account can multiply its API rate at will** (`src/postulo/api/auth.py` (22-38); Informational). The per-account bounds that matter (capture fetches 30/h, phone-number collision answers 20/h) are keyed on the account and hold. …
tiagoagueda added this to the 1.0.0 milestone 2026-10-10 10:58:06 +00:00
Author
Owner

Fixed in 0020410a4 on main.

Fixed in 0020410a4 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#799
No description provided.