SEC2-019: With data protection on, deleting a shared contact skips the copy other members are promised and cascades their referee entries #961

Closed
opened 2026-10-17 10:38:24 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-10-16 in a read-only security review of postulo at 56fc9d531, checked by a second, independent pass.

Severity: Medium · Category: Authentication/Authorization · Reference: CWE-285 (Improper Authorization); OWASP A01:2021 (Broken Access Control)

Location:

  • src/postulo/jobs/views.py:1791-1821 (ContactDeleteView.form_valid, the gdpr.is_offered branch returns without super().form_valid(form))
  • src/postulo/jobs/views.py:87-101 (KeepsOthersCopies.form_valid, the only caller of detach.after_losing on this path)
  • src/postulo/core/gdpr.py:473-510 (erase_contact deletes career_references(contact) and the contact)
  • src/postulo/resume/models.py:837-840 (Reference.contact is CASCADE)

Issue

ContactDeleteView.form_valid has two branches. The plain one calls super().form_valid(form),
which is KeepsOthersCopies.form_valid: it copies the contact to every other member whose own
rows point at it (detach.after_losing, detach.DEPENDENTS["contact"]: listing events, notes,
applications' contact and referred_by, appointments, career Reference, ReferenceLetter,
interview seats) and only then deletes. The data-protection branch instead calls
gdpr.erase_contact(self.object) and returns a redirect; super().form_valid is never reached,
so no copy is made. erase_contact then deletes career_references(contact), which is every
owner's Reference rows since Reference.contact cascades, and then the contact, whose
SET_NULL relations unlink every other member's applications, appointments, events and letters.

Impact

Where the data-protection module is on for the deleter, the owner of a shared contact (or any
member of an owning group) deletes other members' career referee entries outright and strips
the contact from their applications, appointments and history, without the copy that rule 1 of
the threat model ("whoever loses one keeps a copy of what their own data points at", #896)
promises and that the plain branch provides. It is Medium rather than High because the lost
rows are referee entries and links, not documents, and because it needs the module on.

Fix

Run the copy in both branches: call detach.after_losing([record], others, check=False) at the
top of form_valid (before the letters are touched and before erase_contact), or restructure
so the data-protection branch also goes through super().form_valid. Done when a test in
tests/security/test_sharing_sweep.py (or tests/test_gdpr.py) shares a contact with another
person who has a career Reference and an application naming it, deletes it as the owner with
the module on, and asserts the grantee's Reference still exists and points at a copied
contact, and the grantee's application does too.

#896 (closed) - the copy-on-loss rule this branch skips. SEC2-014 - the same view also deletes other members' reference-letter uploads.

Found on 2026-10-16 in a read-only security review of `postulo` at `56fc9d531`, checked by a second, independent pass. **Severity:** Medium · **Category:** Authentication/Authorization · **Reference:** CWE-285 (Improper Authorization); OWASP A01:2021 (Broken Access Control) **Location:** - `src/postulo/jobs/views.py:1791-1821` (`ContactDeleteView.form_valid`, the `gdpr.is_offered` branch returns without `super().form_valid(form)`) - `src/postulo/jobs/views.py:87-101` (`KeepsOthersCopies.form_valid`, the only caller of `detach.after_losing` on this path) - `src/postulo/core/gdpr.py:473-510` (`erase_contact` deletes `career_references(contact)` and the contact) - `src/postulo/resume/models.py:837-840` (`Reference.contact` is `CASCADE`) ## Issue `ContactDeleteView.form_valid` has two branches. The plain one calls `super().form_valid(form)`, which is `KeepsOthersCopies.form_valid`: it copies the contact to every other member whose own rows point at it (`detach.after_losing`, `detach.DEPENDENTS["contact"]`: listing events, notes, applications' `contact` and `referred_by`, appointments, career `Reference`, `ReferenceLetter`, interview seats) and only then deletes. The data-protection branch instead calls `gdpr.erase_contact(self.object)` and returns a redirect; `super().form_valid` is never reached, so no copy is made. `erase_contact` then deletes `career_references(contact)`, which is every owner's `Reference` rows since `Reference.contact` cascades, and then the contact, whose `SET_NULL` relations unlink every other member's applications, appointments, events and letters. ## Impact Where the data-protection module is on for the deleter, the owner of a shared contact (or any member of an owning group) deletes other members' career referee entries outright and strips the contact from their applications, appointments and history, without the copy that rule 1 of the threat model ("whoever loses one keeps a copy of what their own data points at", #896) promises and that the plain branch provides. It is Medium rather than High because the lost rows are referee entries and links, not documents, and because it needs the module on. ## Fix Run the copy in both branches: call `detach.after_losing([record], others, check=False)` at the top of `form_valid` (before the letters are touched and before `erase_contact`), or restructure so the data-protection branch also goes through `super().form_valid`. Done when a test in `tests/security/test_sharing_sweep.py` (or `tests/test_gdpr.py`) shares a contact with another person who has a career `Reference` and an application naming it, deletes it as the owner with the module on, and asserts the grantee's `Reference` still exists and points at a copied contact, and the grantee's application does too. ## Related #896 (closed) - the copy-on-loss rule this branch skips. SEC2-014 - the same view also deletes other members' reference-letter uploads.
tiagoagueda added this to the 0.5.0 milestone 2026-10-17 10:38:25 +00:00
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#961
No description provided.