SEC2-008: PUT /captures/{id}/rendering still hands a captures-only token a capture's address, company, location and source (#761 incomplete) #950

Closed
opened 2026-10-17 10:37:57 +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: Low · Category: Authentication/Authorization · Reference: CWE-285 (Improper Authorization); OWASP A01:2021 (Broken Access Control)

Location:

  • src/postulo/api/api.py:774-803
  • src/postulo/api/api.py:395-409 (_as_output)
  • src/postulo/api/api.py:412-425 (_as_listed, the narrowed shape)

Issue

#761 narrowed GET /captures so that a token holding only captures sees the title, dates
and review link and not the address, company, location or source of the queue (_as_listed
blanks them). The rendering call answers with the unnarrowed row, on both its branches:

@api.put("/captures/{int:pk}/rendering", ..., auth=scope("captures"), ...)
def attach_rendering(request, pk: int):
    capture = get_object_or_404(Capture.objects.for_user(request.auth.owner)...)
    ...
    return Status(200 if already else 201, _as_output(request, capture))

Where both the instance and the account keep renderings (both off by default), a
captures-only token can send a small valid picture to each pending capture whose id it learns
from the list and receive url, company_name, location and source for each in the 201;
sending the same bytes again returns the full row in the 200 as well. The capture's one
rendering slot is spent on that picture, which also stops the browser's real rendering being
kept, so the probe leaves a trace; it still reads what #761 said this scope should not.

Impact

Somebody holding a leaked browser-extension token, on an instance and account where kept
renderings are switched on, reads the company and location of every posting the person is
currently considering, which the threat model says this token is never told.

Fix

Answer attach_rendering with _as_listed(request, capture) instead of _as_output(...) at
api.py:803; the single return covers both the 200 and the 201 (the 201 of POST /captures
may stay whole: it describes the capture the caller just sent). Done when a case in
tests/test_hardening_761.py (or tests/security/test_captured_pages.py) sends a rendering
with a captures-only token and asserts url, company_name, location and source come
back empty, and whole with a token that also holds read.

#761 (closed) - the same weakness on GET /captures; the fix did not reach the PUT answer,
so this is the incomplete half of it. #803 and #536 (closed) - the sibling oracle on
/captures/known, a different route.

Found on 2026-10-16 in a read-only security review of `postulo` at `56fc9d531`, checked by a second, independent pass. **Severity:** Low · **Category:** Authentication/Authorization · **Reference:** CWE-285 (Improper Authorization); OWASP A01:2021 (Broken Access Control) **Location:** - `src/postulo/api/api.py:774-803` - `src/postulo/api/api.py:395-409` (`_as_output`) - `src/postulo/api/api.py:412-425` (`_as_listed`, the narrowed shape) ## Issue #761 narrowed `GET /captures` so that a token holding only `captures` sees the title, dates and review link and not the address, company, location or source of the queue (`_as_listed` blanks them). The rendering call answers with the unnarrowed row, on both its branches: ```python @api.put("/captures/{int:pk}/rendering", ..., auth=scope("captures"), ...) def attach_rendering(request, pk: int): capture = get_object_or_404(Capture.objects.for_user(request.auth.owner)...) ... return Status(200 if already else 201, _as_output(request, capture)) ``` Where both the instance and the account keep renderings (both off by default), a captures-only token can send a small valid picture to each pending capture whose id it learns from the list and receive `url`, `company_name`, `location` and `source` for each in the 201; sending the same bytes again returns the full row in the 200 as well. The capture's one rendering slot is spent on that picture, which also stops the browser's real rendering being kept, so the probe leaves a trace; it still reads what #761 said this scope should not. ## Impact Somebody holding a leaked browser-extension token, on an instance and account where kept renderings are switched on, reads the company and location of every posting the person is currently considering, which the threat model says this token is never told. ## Fix Answer `attach_rendering` with `_as_listed(request, capture)` instead of `_as_output(...)` at `api.py:803`; the single `return` covers both the 200 and the 201 (the 201 of `POST /captures` may stay whole: it describes the capture the caller just sent). Done when a case in `tests/test_hardening_761.py` (or `tests/security/test_captured_pages.py`) sends a rendering with a captures-only token and asserts `url`, `company_name`, `location` and `source` come back empty, and whole with a token that also holds `read`. ## Related #761 (closed) - the same weakness on `GET /captures`; the fix did not reach the `PUT` answer, so this is the incomplete half of it. #803 and #536 (closed) - the sibling oracle on `/captures/known`, a different route.
tiagoagueda added this to the 0.6.0 milestone 2026-10-17 10:37:59 +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#950
No description provided.