PST-031: The API route table and allowed methods are disclosed to unauthenticated callers through 405 answers #760

Closed
opened 2026-10-10 10:56:06 +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-031. 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: information-disclosure / threat-model-deviation
  • Location: src/postulo/api/api.py (883-927; 854-901)
  • Found by: REST API, ownership review of api

Who can do it, and what they need. A stranger on the internet with no token. Precondition: none, only network reach to the instance.

How it works.

  1. Send DELETE/PUT/PATCH/OPTIONS/HEAD to any real API address, e.g. DELETE /api/v1/applications or POST /api/v1/me, with no Authorization header.
  2. django-ninja's PathView finds no operation for the method and returns 405 before any auth callback runs; the wrapper in api.py dresses it as a problem document and copies the Allow header.
  3. A non-existent address returns the 401 from _no_such_address instead.
  4. By comparing 405 (with Allow: GET, POST) against 401 the attacker enumerates every API address and its methods without a token, and GET /api/v1/ returns Django's HTML 404 page rather than a 401. Verified: client.delete('/api/v1/applications') -> 405 Allow: GET, POST application/problem+json; client.head(...) -> 405; client.get('/api/v1/does-not-exist') -> 401; client.get('/api/v1/') -> 404 text/html.

Impact. Fingerprints a Postulo instance and its full route table to anyone, contradicting the threat model's 'the API answers 401 to everything without a live token' and the stated purpose of _no_such_address ('so the route table is not probed without one'). No data is exposed.

Evidence.

api.py 902-909: `response = view(request, *args, **kwargs)\n        if response.status_code == 405 and response.get("Content-Type") != problems.CONTENT_TYPE:\n            refusal = problems.refuse(request, api, 405)\n            if "Allow" in response:\n                refusal["Allow"] = response["Allow"]` -- no token is looked up on this path. api.py 923-927: `if scheme.lower() == "bearer" and lookup(raw.strip()) is not None: return problems.refuse(request, api, 404, ...)\n    return problems.refuse(request, api, 401, problems.no_token())` only covers addresses nothing owns.

Verification (verdict: confirmed; severity after verification: Low). Low (hardening gap versus a stated project claim). Attacker is an unauthenticated network peer; effect is route-table/method enumeration and API fingerprinting only (the route table is public in the open-source repo). Precise lines: api.py:875-901 (wrapper), api.py:904-916 (_no_such_address), ninja operation.py:673-677 and ninja/openapi/views.py:13-16 (HTML 404 at the API root). Remediation: answer 401 for a request lacking a live bearer before any 405 (e.g. in the wrapper, run lookup() and return the 401 problem when there is no live token), and give the root path a 401/problem response instead of ninja's default_home 404.

Suggested remediation. In _answer_a_wrong_method_as_a_problem, when the wrapped view returns 405, apply the same token check _no_such_address makes and answer 401 (without Allow) when no live token is presented; alternatively run the method check after authentication. Put _no_such_address (or a 401) in front of ninja's root default_home pattern so GET /api/v1/ is also a 401 problem document.

Also covered by this issue.

  • Wrong-method requests answer 405 (with Allow header) before any token check, so a stranger can probe the API route table (src/postulo/api/api.py (854-901); Low). Contradicts the threat model's promise that 'the API answers 401 to everything without a live token' and the stated purpose of _no_such_address (#430: 'so the route table is not probed without one'). …
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-031**. 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:** information-disclosure / threat-model-deviation - **Location:** `src/postulo/api/api.py` (883-927; 854-901) - **Found by:** REST API, ownership review of api **Who can do it, and what they need.** A stranger on the internet with no token. Precondition: none, only network reach to the instance. **How it works.** 1. Send DELETE/PUT/PATCH/OPTIONS/HEAD to any real API address, e.g. `DELETE /api/v1/applications` or `POST /api/v1/me`, with no Authorization header. 2. django-ninja's PathView finds no operation for the method and returns 405 before any auth callback runs; the wrapper in api.py dresses it as a problem document and copies the `Allow` header. 3. A non-existent address returns the 401 from `_no_such_address` instead. 4. By comparing 405 (with `Allow: GET, POST`) against 401 the attacker enumerates every API address and its methods without a token, and `GET /api/v1/` returns Django's HTML 404 page rather than a 401. Verified: `client.delete('/api/v1/applications')` -> 405 `Allow: GET, POST` application/problem+json; `client.head(...)` -> 405; `client.get('/api/v1/does-not-exist')` -> 401; `client.get('/api/v1/')` -> 404 text/html. **Impact.** Fingerprints a Postulo instance and its full route table to anyone, contradicting the threat model's 'the API answers 401 to everything without a live token' and the stated purpose of `_no_such_address` ('so the route table is not probed without one'). No data is exposed. **Evidence.** ```text api.py 902-909: `response = view(request, *args, **kwargs)\n if response.status_code == 405 and response.get("Content-Type") != problems.CONTENT_TYPE:\n refusal = problems.refuse(request, api, 405)\n if "Allow" in response:\n refusal["Allow"] = response["Allow"]` -- no token is looked up on this path. api.py 923-927: `if scheme.lower() == "bearer" and lookup(raw.strip()) is not None: return problems.refuse(request, api, 404, ...)\n return problems.refuse(request, api, 401, problems.no_token())` only covers addresses nothing owns. ``` **Verification** (verdict: confirmed; severity after verification: Low). Low (hardening gap versus a stated project claim). Attacker is an unauthenticated network peer; effect is route-table/method enumeration and API fingerprinting only (the route table is public in the open-source repo). Precise lines: api.py:875-901 (wrapper), api.py:904-916 (_no_such_address), ninja operation.py:673-677 and ninja/openapi/views.py:13-16 (HTML 404 at the API root). Remediation: answer 401 for a request lacking a live bearer before any 405 (e.g. in the wrapper, run lookup() and return the 401 problem when there is no live token), and give the root path a 401/problem response instead of ninja's default_home 404. **Suggested remediation.** In `_answer_a_wrong_method_as_a_problem`, when the wrapped view returns 405, apply the same token check `_no_such_address` makes and answer 401 (without `Allow`) when no live token is presented; alternatively run the method check after authentication. Put `_no_such_address` (or a 401) in front of ninja's root `default_home` pattern so `GET /api/v1/` is also a 401 problem document. **Also covered by this issue.** - **Wrong-method requests answer 405 (with Allow header) before any token check, so a stranger can probe the API route table** (`src/postulo/api/api.py` (854-901); Low). Contradicts the threat model's promise that 'the API answers 401 to everything without a live token' and the stated purpose of _no_such_address (#430: 'so the route table is not probed without one'). …
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:39 +00:00
Author
Owner

Fixed in b4fff603a on main.

Fixed in b4fff603a 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#760
No description provided.