SEC2-009: Wrong tokens sent to openapi.json, an unknown API address or a wrong method are neither counted nor barred (#799 incomplete) #951

Closed
opened 2026-10-17 10:37:59 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-10-16 in a read-only security review of postulo at 56fc9d531, checked by a second, independent pass.

Severity: Informational · Category: Authentication/Authorization · Reference: CWE-307 (Improper Restriction of Excessive Authentication Attempts); OWASP A07:2021 (Identification and Authentication Failures)

Location:

  • src/postulo/api/auth.py:166-200 (for_readers_of_the_api, line 186 calls lookup directly; the 401 is built by hand at 191-196)
  • src/postulo/api/api.py:945-951 (_has_a_live_token, used by the 405 wrapper at 912-942 and _no_such_address at 953-960)
  • src/postulo/api/auth.py:42-59 (the counting that these paths skip)

Issue

#799 added _barred/_wrong_token to TokenAuth and ScopedAuth, so sixty wrong tokens
from one address in an hour bar it. Its remediation note named _no_such_address and
for_readers_of_the_api as well, and those still call lookup() bare:

scheme, _space, raw = request.headers.get("Authorization", "").partition(" ")
if scheme.lower() == "bearer" and lookup(raw.strip()) is not None:
    return view(request, *args, **kwargs)
def _has_a_live_token(request) -> bool:
    scheme, _space, raw = request.META.get("HTTP_AUTHORIZATION", "").partition(" ")
    return scheme.lower() == "bearer" and lookup(raw.strip()) is not None

So an address that is barred on every operation is still answered on GET /api/v1/openapi.json,
on any unknown path under /api/v1/ and on a wrong method, and guesses made there are not
counted. The token is 32 random bytes hashed with SHA-256, so guessing is not a credential
risk; this is the project's own hardening applied unevenly, as #799 itself was rated. The
schema view also spends no allowance for a live token, and get_openapi_schema rebuilds the
whole description per request.

Impact

None beyond cheap 401s and schema builds, which the threat model places with the reverse
proxy. Recorded so the pattern is whole.

Fix

One helper in auth.py, say live_token(request) -> ApiToken | None, that runs _barred,
lookup and _wrong_token as the auth classes do, used by for_readers_of_the_api and
_has_a_live_token. for_readers_of_the_api is a plain Django view with no API handler over
it, so it must catch the problems.Refused the helper raises and build the 429 by hand, as it
already builds the 401 (auth.py:191-196). Done when a test beside tests/test_hardening_799.py,
parametrised over openapi.json, an unknown /api/v1/ path and a DELETE on /api/v1/me,
shows a barred address answered 429 on each and the guesses counted.

#799 (closed) - names these two paths in its suggested remediation; the landed fix covers the
two HttpBearer classes only. #760 and #430 (closed) - built the 405 and unknown-address wrappers.

Found on 2026-10-16 in a read-only security review of `postulo` at `56fc9d531`, checked by a second, independent pass. **Severity:** Informational · **Category:** Authentication/Authorization · **Reference:** CWE-307 (Improper Restriction of Excessive Authentication Attempts); OWASP A07:2021 (Identification and Authentication Failures) **Location:** - `src/postulo/api/auth.py:166-200` (`for_readers_of_the_api`, line 186 calls `lookup` directly; the 401 is built by hand at 191-196) - `src/postulo/api/api.py:945-951` (`_has_a_live_token`, used by the 405 wrapper at 912-942 and `_no_such_address` at 953-960) - `src/postulo/api/auth.py:42-59` (the counting that these paths skip) ## Issue #799 added `_barred`/`_wrong_token` to `TokenAuth` and `ScopedAuth`, so sixty wrong tokens from one address in an hour bar it. Its remediation note named `_no_such_address` and `for_readers_of_the_api` as well, and those still call `lookup()` bare: ```python scheme, _space, raw = request.headers.get("Authorization", "").partition(" ") if scheme.lower() == "bearer" and lookup(raw.strip()) is not None: return view(request, *args, **kwargs) ``` ```python def _has_a_live_token(request) -> bool: scheme, _space, raw = request.META.get("HTTP_AUTHORIZATION", "").partition(" ") return scheme.lower() == "bearer" and lookup(raw.strip()) is not None ``` So an address that is barred on every operation is still answered on `GET /api/v1/openapi.json`, on any unknown path under `/api/v1/` and on a wrong method, and guesses made there are not counted. The token is 32 random bytes hashed with SHA-256, so guessing is not a credential risk; this is the project's own hardening applied unevenly, as #799 itself was rated. The schema view also spends no allowance for a live token, and `get_openapi_schema` rebuilds the whole description per request. ## Impact None beyond cheap 401s and schema builds, which the threat model places with the reverse proxy. Recorded so the pattern is whole. ## Fix One helper in `auth.py`, say `live_token(request) -> ApiToken | None`, that runs `_barred`, `lookup` and `_wrong_token` as the auth classes do, used by `for_readers_of_the_api` and `_has_a_live_token`. `for_readers_of_the_api` is a plain Django view with no API handler over it, so it must catch the `problems.Refused` the helper raises and build the 429 by hand, as it already builds the 401 (`auth.py:191-196`). Done when a test beside `tests/test_hardening_799.py`, parametrised over `openapi.json`, an unknown `/api/v1/` path and a `DELETE` on `/api/v1/me`, shows a barred address answered 429 on each and the guesses counted. ## Related #799 (closed) - names these two paths in its suggested remediation; the landed fix covers the two `HttpBearer` classes only. #760 and #430 (closed) - built the 405 and unknown-address wrappers.
tiagoagueda added this to the 1.0.0 milestone 2026-10-17 10:38:01 +00:00
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#951
No description provided.