PST-023: Webhook delivery rows keep the full notification body forever and escape export and erasure #752

Closed
opened 2026-10-10 10:56:00 +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-023. 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: privacy / data retention
  • Location: src/postulo/notifications/webhooks.py (61-80)
  • Found by: completeness review: shipped plugins

Who can do it, and what they need. Not an attacker but a data-protection gap. The party affected is the account holder who deletes a webhook connection or an application, or anyone with a copy of the database or backup.

How it works.

  1. A person adds a webhook connection. status_changed, interview_scheduled and offer_recorded are on by default for webhooks.
  2. Each event creates a WebhookDelivery whose body is the signed JSON text. It contains the role and company, the status-change note (free text), the offer terms and interview times.
  3. Nothing deletes SENT or GIVEN_UP rows: only webhooks.py references WebhookDelivery, and send_pending, errands.forget_old and inbox.forget_old do not touch them.
  4. Deleting the connection sets connection=NULL (on_delete=SET_NULL) and keeps the body. Deleting the application leaves its notes and offer terms in the rows. Contact erasure, retention dry-run and the account archive (core/gdpr.py, core/export.py) do not mention the table. Only deleting the whole account cascades through owner.

Impact. Offer terms, private status notes and company names persist indefinitely in plaintext in the database and in every backup after the person has deleted the connection or the record, which defeats the stated erasure and retention behaviour. Limited to the owner's own data.

Evidence.

webhooks.py:73-79 `return WebhookDelivery.objects.create(owner=connection.owner, connection=connection, event=notification.event, key=key, body=plugin.encode(plugin.payload_for(notification)), next_attempt_at=timezone.now())`. models.py:62 `on_delete=models.SET_NULL`. slow.py `body=payload.get("terms", "")` for offer_recorded and `body=payload.get("note", "")` for status_changed. `grep WebhookDelivery` finds only models.py, webhooks.py and migrations.

Verification (verdict: confirmed; severity after verification: Low). Keep low. This is a privacy and retention gap, not an attacker path, and it concerns only the owner's own data. The strongest wording is that an invisible, unbounded outbox keeps free-text notes and offer terms for ever, after the user deletes the connection or the application. The receiver already had the data when the delivery was SENT, so the local body is needed only for retries. Remediation: blank or delete the body once a row is SENT or GIVEN_UP past a short window, or add webhooks to a scheduled forget_old like errands and inbox. Optionally delete deliveries when their connection or application is deleted, and list or declare the table in the archive as carried or not carried.

Suggested remediation. Prune SENT and GIVEN_UP rows after a short retention (a few days) in the scheduler pass, delete rows when their connection is deleted (CASCADE), and clear the body once it is sent. Include or explicitly name the table in the account archive and the contact-erasure and retention reports.

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-023**. 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:** privacy / data retention - **Location:** `src/postulo/notifications/webhooks.py` (61-80) - **Found by:** completeness review: shipped plugins **Who can do it, and what they need.** Not an attacker but a data-protection gap. The party affected is the account holder who deletes a webhook connection or an application, or anyone with a copy of the database or backup. **How it works.** 1. A person adds a webhook connection. status_changed, interview_scheduled and offer_recorded are on by default for webhooks. 2. Each event creates a WebhookDelivery whose `body` is the signed JSON text. It contains the role and company, the status-change note (free text), the offer `terms` and interview times. 3. Nothing deletes SENT or GIVEN_UP rows: only webhooks.py references WebhookDelivery, and send_pending, errands.forget_old and inbox.forget_old do not touch them. 4. Deleting the connection sets `connection=NULL` (on_delete=SET_NULL) and keeps the body. Deleting the application leaves its notes and offer terms in the rows. Contact erasure, retention dry-run and the account archive (core/gdpr.py, core/export.py) do not mention the table. Only deleting the whole account cascades through `owner`. **Impact.** Offer terms, private status notes and company names persist indefinitely in plaintext in the database and in every backup after the person has deleted the connection or the record, which defeats the stated erasure and retention behaviour. Limited to the owner's own data. **Evidence.** ```text webhooks.py:73-79 `return WebhookDelivery.objects.create(owner=connection.owner, connection=connection, event=notification.event, key=key, body=plugin.encode(plugin.payload_for(notification)), next_attempt_at=timezone.now())`. models.py:62 `on_delete=models.SET_NULL`. slow.py `body=payload.get("terms", "")` for offer_recorded and `body=payload.get("note", "")` for status_changed. `grep WebhookDelivery` finds only models.py, webhooks.py and migrations. ``` **Verification** (verdict: confirmed; severity after verification: Low). Keep low. This is a privacy and retention gap, not an attacker path, and it concerns only the owner's own data. The strongest wording is that an invisible, unbounded outbox keeps free-text notes and offer terms for ever, after the user deletes the connection or the application. The receiver already had the data when the delivery was SENT, so the local body is needed only for retries. Remediation: blank or delete the body once a row is SENT or GIVEN_UP past a short window, or add webhooks to a scheduled forget_old like errands and inbox. Optionally delete deliveries when their connection or application is deleted, and list or declare the table in the archive as carried or not carried. **Suggested remediation.** Prune SENT and GIVEN_UP rows after a short retention (a few days) in the scheduler pass, delete rows when their connection is deleted (CASCADE), and clear the body once it is sent. Include or explicitly name the table in the account archive and the contact-erasure and retention reports.
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:32 +00:00
Author
Owner

Fixed in 39db5cbf5 on main.

Fixed in 39db5cbf5 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#752
No description provided.