PST-034: A listings:bind token can read back an existing history entry by replaying its external_id #763

Closed
opened 2026-10-10 10:56:09 +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-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.


  • Severity: Low
  • Category: scope-creep / information-disclosure
  • Location: src/postulo/api/routers/listings.py (410-466)
  • Found by: REST API

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 an external_id the 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.

  1. GET /api/v1/listings/choices to learn listing ids (allowed to the scope).
  2. POST /api/v1/listings/{id}/events with {"external_id": "<guess>"} and an empty summary.
  3. record_listing_event finds the existing entry for that external_id and returns it unchanged; the router answers 200 with listing_event_out(event), i.e. the entry's summary, 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.

listings.py 455-466: `event, created = record_listing_event(... external_id=payload.external_id)\n    return Status(201 if created else 200, listing_event_out(event))`; history.py 166-170: `if external_id:\n        found = ListingEvent.objects.filter(posting=posting, external_id=external_id).first()\n        if found is not None:\n            return found, False`.

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 created is False and the token lacks read, 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's actor equals this token's actor before echoing its text.

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-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. --- - **Severity:** Low - **Category:** scope-creep / information-disclosure - **Location:** `src/postulo/api/routers/listings.py` (410-466) - **Found by:** REST API **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 an `external_id` the 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.** 1. `GET /api/v1/listings/choices` to learn listing ids (allowed to the scope). 2. `POST /api/v1/listings/{id}/events` with `{"external_id": "<guess>"}` and an empty summary. 3. `record_listing_event` finds the existing entry for that external_id and returns it unchanged; the router answers 200 with `listing_event_out(event)`, i.e. the entry's `summary`, `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.** ```text listings.py 455-466: `event, created = record_listing_event(... external_id=payload.external_id)\n return Status(201 if created else 200, listing_event_out(event))`; history.py 166-170: `if external_id:\n found = ListingEvent.objects.filter(posting=posting, external_id=external_id).first()\n if found is not None:\n return found, False`. ``` **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 `created` is False and the token lacks `read`, 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's `actor` equals this token's actor before echoing its text.
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:40 +00:00
Author
Owner

Fixed in 73b361bd8 on main.

Fixed in 73b361bd8 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#763
No description provided.