SEC2-014: Deleting a shared contact deletes other members' reference-letter uploads #956
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#956
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 on 2026-10-16 in a read-only security review of
postuloat56fc9d531, checked by a second, independent pass.Severity: High · Category: Authentication/Authorization · Reference: CWE-285 (Improper Authorization); CWE-639 (Authorization Bypass Through User-Controlled Key); OWASP A01:2021 (Broken Access Control)
Location:
src/postulo/jobs/views.py:1764-1821(ContactDeleteView:get_context_data, andform_validwith the singlelettersassignment at 1796)src/postulo/documents/models.py:896-922(ReferenceLetter, anOwnedModelwhoserefereeis aSET_NULLlink to the contact)src/postulo/core/gdpr.py:179-201(references) and:473-510(erase_contact, whose report counts every owner's rows)Issue
Since #896 a contact may be owned by one person or group and shared with others. A
ReferenceLetterstays person-owned (OwnedModel) and points at the referee contact withSET_NULL, so two members who both know a shared referee may each hold their own uploadedletter from them. The delete view reads the letters through the contact's reverse relation
with no owner narrowing, both to count them and to delete them:
Every other place that reads this relation narrows it (
ContactUpdateView.get_context_datafilters
reference_lettersbyowner=self.request.user, with the comment "The person's ownletters only (#896)"). The delete path was not updated with #896.
access = MANAGEmeans theowner (or a member of the owning group) reaches this view; the letters it deletes belong to
whoever the contact was shared with.
KeepsOthersCopiesanddetach.DEPENDENTSrun only afterthe uploads are already gone, so nothing hands the grantee a copy.
With the data-protection module on, the same view goes through
gdpr.erase_contact, whosereport (
references():ReferenceLetter.objects.filter(referee=contact), and the same forapplications, referrals, interviews, appointments and listing events) counts every owner's rows
and is shown to the deleter as the summary.
Impact
A person who owns a contact and shares it (or any member of a group that owns it) can, by
deleting the contact with "Delete the letters they wrote as well" ticked, delete the
UploadedDocumentrows and files of every other member who recorded a letter from thatreferee. The count in the dialog, and the erasure summary when the data-protection module is
on, also tell the owner how many such rows other people hold. This breaks the threat-model
promise that documents are "theirs whatever is shared" and is a cross-account deletion of
another member's files.
Fix
Narrow both reads to the requester's own rows, at the single assignment the two branches share:
and let
detach.after_losing(already listingReferenceLetter.referee) give other memberswhat they need. In
gdpr.erase_contact, narrow the counts the deleter is shown to thedeleter's rows, or word the summary without numbers for rows that are not theirs. Done when a
test in
tests/security/test_sharing_sweep.py, run with the data-protection module both on andoff, shares a contact read-only and read/write with another person who uploads a reference
letter from it, has the owner delete the contact with
delete_letters=1, and asserts thegrantee's
UploadedDocumentstill exists and the dialog and summary counted only the owner'srows.
Related
#896 (closed) - introduced shared contacts; its text does not mention letters. #666 and #696 (closed) - the delete-letters option and the referee rules, both written before sharing. SEC2-019 - the same view's data-protection branch also skips the copy other members are promised.