PST-034: A listings:bind token can read back an existing history entry by replaying its external_id #763
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#763
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-034. 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/listings.py(410-466)Who can do it, and what they need. Someone holding a leaked mail-client token (
listings:bind). Precondition: an entry on one of the owner's listings carries anexternal_idthe attacker knows or can guess (message ids are predictable for some mailers; another client or plugin may have written entries with sequential ids).How it works.
GET /api/v1/listings/choicesto learn listing ids (allowed to the scope).POST /api/v1/listings/{id}/eventswith{"external_id": "<guess>"}and an empty summary.record_listing_eventfinds the existing entry for that external_id and returns it unchanged; the router answers 200 withlisting_event_out(event), i.e. the entry'ssummary,body(up to 40,000 characters of the email/note),contact_id,document_id,actor. Verified: an owner-written note with external_id 'msg-1' and body 'SECRET NOTE BODY' was returned in full to a bind token.Impact. The threat model says a bind token 'reads a title, a company, a place and a state and never the search' and 'the worst it can do is add entries'. Replaying an id turns the write call into a read of existing history text. Scope is one entry per correct guess, so low.
Evidence.
Verification (verdict: confirmed; severity after verification: Low). File and lines are right (listings.py 144-194, history.py 166-170). Narrow the precondition: the entry must have been written with an external_id by an API client, plugin or import, since UI-written notes have none, and the attacker must know or guess that id. Where the same mail client wrote the entry, it already holds that content. Suggested fix: on an idempotent replay return only the identifying fields (id, external_id, kind), or return the full entry only when its actor matches the calling token's actor.
Suggested remediation. When
createdis False and the token lacksread, answer 200 with only the identifying fields (id, listing_id, external_id, created_at) rather than the whole entry; or require that a replayed entry'sactorequals this token's actor before echoing its text.Fixed in
73b361bd8on main.