PST-036: A write-only token reads whole records through no-op writes #765

Closed
opened 2026-10-10 10:56:10 +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-036. 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: scope / least-privilege boundary
  • Location: src/postulo/api/routers/applications.py (applications.py:221-248)
  • Found by: ownership review of api

Who can do it, and what they need. Someone holding a token issued with only the write scope (an agent integration the person deliberately did not grant read), or a thief of such a token.

How it works.

  1. GET /api/v1/applications/{pk} and GET /api/v1/companies/{pk} are refused with 403 (scope read missing).
  2. Send POST /api/v1/applications/{pk}/status with the status the application already has: change_status() sees previous == new_status and returns None, nothing is written, yet the call answers application_out(detail=True) with every timeline entry's body, every reminder, interview, offer and sent_document_ids.
  3. Send PATCH /api/v1/companies/{pk} with {}: company.save() runs and the answer is company_out(detail=True) with every contact's name, email, phone numbers, web links, messaging handles and notes.
  4. PATCH /interviews/{pk} and PATCH /reminders/{pk} with {} likewise return the record; POST /listings/{pk}/restore on a listing already 'new' changes nothing and returns description plus the whole history (events, including email bodies a mail client filed).
  5. Iterate pk 1..N to read the whole account. Verified with a scratch test: a write-only token got the private timeline body through a same-status POST (event count unchanged) and a contact's email and the company's notes through PATCH {}.

Impact. The read/write split is not a confidentiality boundary: a token the person scoped to writing can read everything the records hold, including other people's personal data (contacts) and filed email bodies. The project decided in #309 that a PATCH answers with the record (tests/security/test_profile_api.py:58), so this is partly by design; but the API description (api.py:131-134) says 'What it did not change it is not shown' and _prints_out withholds offered for exactly this reason (documents.py:55-61), while the same nested read-only collections elsewhere (events, contacts, history) …

Evidence.

src/postulo/api/routers/applications.py:241-248:
    change_status(application, payload.status, note=payload.note, actor=actor_of(request), end_reason=payload.end_reason)
    return application_out(request, _detail(request, pk), detail=True)

src/postulo/applications/services.py:163-168 (same status writes nothing):
    if previous == new_status:
        if end_reason: ...
        return None

src/postulo/api/schemas.py:1273-1277:
    if detail:
        data["events"] = [event_out(event) for event in application.events.all()]
        data["reminders"] = ...; data["interviews"] = ...; data["offers"] = ...

src/postulo/api/routers/companies.py:370-386:
    def patch_company(request, pk: int, p
…

Verification (verdict: confirmed; severity after verification: Low). Line numbers in the finding do not match the current tree: set_status is applications.py:128-161, get_application 121-125; patch_company companies.py:103-122; shortlist/discard/restore listings.py:245-264; application_out detail block schemas.py:1385-1390; company_out detail block schemas.py:1446-1448. The finding is partly a documented decision (#309, test_profile_api.py:58), so the real defect is narrower: the description's sentence 'What it did not change it is not shown' and the CV-only gate imply a boundary that the other write calls do not keep. Remediation: either strip read-only nested collections (events, contacts, history, description) from write answers for a token without 'read', as _prints_out does, or state in the scope label and description that 'write' can read whatever a write call returns.

Suggested remediation. Decide one rule and apply it everywhere: either (a) gate the nested read-only collections (an application's events/reminders/interviews/offers, a company's contacts, a listing's events and description) on request.auth.has_scope('read') exactly as _prints_out(offered=...) does, answering a write-only token with the row it changed and no more; or (b) keep the behaviour and make the write scope label and the API description say plainly that write reads every record it can touch, including contacts and the timeline, so the person ticking the box is not misled. Add a security test mirroring tests/security/test_profile_api.py for applications and companies.

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-036**. 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:** scope / least-privilege boundary - **Location:** `src/postulo/api/routers/applications.py` (applications.py:221-248) - **Found by:** ownership review of api **Who can do it, and what they need.** Someone holding a token issued with only the `write` scope (an agent integration the person deliberately did not grant `read`), or a thief of such a token. **How it works.** 1. `GET /api/v1/applications/{pk}` and `GET /api/v1/companies/{pk}` are refused with 403 (scope `read` missing). 2. Send `POST /api/v1/applications/{pk}/status` with the status the application already has: change_status() sees previous == new_status and returns None, nothing is written, yet the call answers application_out(detail=True) with every timeline entry's `body`, every reminder, interview, offer and sent_document_ids. 3. Send `PATCH /api/v1/companies/{pk}` with `{}`: company.save() runs and the answer is company_out(detail=True) with every contact's name, email, phone numbers, web links, messaging handles and notes. 4. `PATCH /interviews/{pk}` and `PATCH /reminders/{pk}` with `{}` likewise return the record; `POST /listings/{pk}/restore` on a listing already 'new' changes nothing and returns `description` plus the whole history (`events`, including email bodies a mail client filed). 5. Iterate pk 1..N to read the whole account. Verified with a scratch test: a write-only token got the private timeline body through a same-status POST (event count unchanged) and a contact's email and the company's notes through `PATCH {}`. **Impact.** The `read`/`write` split is not a confidentiality boundary: a token the person scoped to writing can read everything the records hold, including other people's personal data (contacts) and filed email bodies. The project decided in #309 that a PATCH answers with the record (tests/security/test_profile_api.py:58), so this is partly by design; but the API description (api.py:131-134) says 'What it did not change it is not shown' and `_prints_out` withholds `offered` for exactly this reason (documents.py:55-61), while the same nested read-only collections elsewhere (events, contacts, history) … **Evidence.** ```text src/postulo/api/routers/applications.py:241-248: change_status(application, payload.status, note=payload.note, actor=actor_of(request), end_reason=payload.end_reason) return application_out(request, _detail(request, pk), detail=True) src/postulo/applications/services.py:163-168 (same status writes nothing): if previous == new_status: if end_reason: ... return None src/postulo/api/schemas.py:1273-1277: if detail: data["events"] = [event_out(event) for event in application.events.all()] data["reminders"] = ...; data["interviews"] = ...; data["offers"] = ... src/postulo/api/routers/companies.py:370-386: def patch_company(request, pk: int, p … ``` **Verification** (verdict: confirmed; severity after verification: Low). Line numbers in the finding do not match the current tree: set_status is applications.py:128-161, get_application 121-125; patch_company companies.py:103-122; shortlist/discard/restore listings.py:245-264; application_out detail block schemas.py:1385-1390; company_out detail block schemas.py:1446-1448. The finding is partly a documented decision (#309, test_profile_api.py:58), so the real defect is narrower: the description's sentence 'What it did not change it is not shown' and the CV-only gate imply a boundary that the other write calls do not keep. Remediation: either strip read-only nested collections (events, contacts, history, description) from write answers for a token without 'read', as _prints_out does, or state in the scope label and description that 'write' can read whatever a write call returns. **Suggested remediation.** Decide one rule and apply it everywhere: either (a) gate the nested read-only collections (an application's `events`/`reminders`/`interviews`/`offers`, a company's `contacts`, a listing's `events` and `description`) on `request.auth.has_scope('read')` exactly as `_prints_out(offered=...)` does, answering a write-only token with the row it changed and no more; or (b) keep the behaviour and make the `write` scope label and the API description say plainly that `write` reads every record it can touch, including contacts and the timeline, so the person ticking the box is not misled. Add a security test mirroring tests/security/test_profile_api.py for applications and companies.
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:42 +00:00
Author
Owner

Fixed in a9a1e4107 on main.

Fixed in a9a1e4107 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#765
No description provided.