PST-036: A write-only token reads whole records through no-op writes #765
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#765
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found in the security audit of 2026-10-10 (
SECURITY-AUDIT-2026-10-10.md), run againstmainatc30851bfrom 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.
src/postulo/api/routers/applications.py(applications.py:221-248)Who can do it, and what they need. Someone holding a token issued with only the
writescope (an agent integration the person deliberately did not grantread), or a thief of such a token.How it works.
GET /api/v1/applications/{pk}andGET /api/v1/companies/{pk}are refused with 403 (scopereadmissing).POST /api/v1/applications/{pk}/statuswith 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'sbody, every reminder, interview, offer and sent_document_ids.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.PATCH /interviews/{pk}andPATCH /reminders/{pk}with{}likewise return the record;POST /listings/{pk}/restoreon a listing already 'new' changes nothing and returnsdescriptionplus the whole history (events, including email bodies a mail client filed).PATCH {}.Impact. The
read/writesplit 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_outwithholdsofferedfor exactly this reason (documents.py:55-61), while the same nested read-only collections elsewhere (events, contacts, history) …Evidence.
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'scontacts, a listing'seventsanddescription) onrequest.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 thewritescope label and the API description say plainly thatwritereads 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.Fixed in
a9a1e4107on main.