SEC2-016: Delete-confirmation facts count other members' private rows and are drawn for every reader #958

Closed
opened 2026-10-17 10:38:18 +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: Data Exposure · Reference: CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor); OWASP A01:2021 (Broken Access Control)

Location:

  • src/postulo/jobs/views.py:1394-1471 (CompanyDeleteView.get_context_data), :1948-2034 (PostingDeleteView.get_context_data), :1769-1789 (ContactDeleteView.get_context_data)
  • src/postulo/jobs/views.py:870, 1921 (CompanyDetailView and PostingDetailView call deletions.facts_for(...) for every reader, access = READ)
  • src/postulo/templates/jobs/company_detail.html:60-66, 552-553 and src/postulo/templates/jobs/posting_detail.html:200-201 (the Delete button and the <c-delete-dialog :facts="deletion"> are drawn with no manages gate)
  • src/postulo/core/gdpr.py:179-201 (references) and :473-510 (erase_contact, the same unnarrowed counts in its summary)

Issue

The consequences a deletion lists are computed over the whole record, not over the reader's
own rows:

# CompanyDeleteView.get_context_data
applications = Application.objects.filter(posting__company=company)
postings = company.postings.count()
applied = applications.count()
contacts = company.contacts.count()
appointments = company.appointments.count()
...
sent = RenderedDocument.objects.filter(application__in=applications).count()
referees = Reference.objects.filter(contact__company=company).count()

Likewise PostingDeleteView counts self.object.applications.all(), self.object.events.all()
(every person's history entries), self.object.captures.all() and the RenderedDocument rows
of everybody's applications, worded as "documents you sent". On a shared company or
listing these include applications, appointments, contacts, references, captures and history
entries that belong to other members: rows the threat model says are "theirs whatever is
shared", and that every other query on these pages narrows (own_applications,
Contact.objects.visible_to, events ... owner=user). The erasure summary of a contact under
the data-protection module (gdpr.erase_contact) has the same unnarrowed counts and is shown
to the deleter.

Since #854 the facts are computed by deletions.facts_for inside the detail views, which
have access = READ, and the dialog markup is written into the page for every reader. A
read-only grantee (and a superuser) therefore receives the counts in the page source; the
Delete button in the header is not gated either, so the dialog opens, and only the POST is
refused (404 from MANAGE).

Impact

Another member of the instance (a read-only grantee is enough) learns how many applications,
appointments, contacts, references and sent documents other people hold against a shared
company or listing: counts only, but exactly the kind of data the threat model keeps
per-person. The owner is also misled: "The 3 documents you sent are kept" may count documents
they never sent.

Fix

  • Narrow the personal rows to the requester (Application.objects.filter(..., owner=request.user), company.appointments.filter(owner=...), Reference.objects.filter(owner=...), self.object.events.filter(owner=...), self.object.captures.filter(owner=...), RenderedDocument through the requester's applications) and keep only the record-level counts (postings, visible contacts) unnarrowed; say separately, without a number, that what other people keep of their own stays with them as a copy (which is what KeepsOthersCopies does). Do the same for the counts in gdpr.erase_contact's summary.
  • Gate the Delete/Edit/Merge buttons and the dialog on sharing.panel(...)["manages"], already in the context of both pages, and compute facts_for only when manages, so a read-only grantee is neither offered them nor sent the counts.

Done when tests/security/test_shared_leaks.py::test_the_pages_show_nothing_of_the_owner_s also
asserts that the delete dialog on a shared company and listing counts none of the owner's
applications, appointments or documents, and that a read-only grantee's page has no delete
dialog.

#854 (closed) - moved the facts into the detail pages; says nothing about grantees. #217 (closed) - the delete consequences, written before sharing. SEC2-014 - the contact-erasure summary's counts are also covered there.

Found on 2026-10-16 in a read-only security review of `postulo` at `56fc9d531`, checked by a second, independent pass. **Severity:** Medium · **Category:** Data Exposure · **Reference:** CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor); OWASP A01:2021 (Broken Access Control) **Location:** - `src/postulo/jobs/views.py:1394-1471` (`CompanyDeleteView.get_context_data`), `:1948-2034` (`PostingDeleteView.get_context_data`), `:1769-1789` (`ContactDeleteView.get_context_data`) - `src/postulo/jobs/views.py:870, 1921` (`CompanyDetailView` and `PostingDetailView` call `deletions.facts_for(...)` for every reader, `access = READ`) - `src/postulo/templates/jobs/company_detail.html:60-66, 552-553` and `src/postulo/templates/jobs/posting_detail.html:200-201` (the Delete button and the `<c-delete-dialog :facts="deletion">` are drawn with no `manages` gate) - `src/postulo/core/gdpr.py:179-201` (`references`) and `:473-510` (`erase_contact`, the same unnarrowed counts in its summary) ## Issue The consequences a deletion lists are computed over the whole record, not over the reader's own rows: ```python # CompanyDeleteView.get_context_data applications = Application.objects.filter(posting__company=company) postings = company.postings.count() applied = applications.count() contacts = company.contacts.count() appointments = company.appointments.count() ... sent = RenderedDocument.objects.filter(application__in=applications).count() referees = Reference.objects.filter(contact__company=company).count() ``` Likewise `PostingDeleteView` counts `self.object.applications.all()`, `self.object.events.all()` (every person's history entries), `self.object.captures.all()` and the `RenderedDocument` rows of everybody's applications, worded as "documents **you** sent". On a shared company or listing these include applications, appointments, contacts, references, captures and history entries that belong to other members: rows the threat model says are "theirs whatever is shared", and that every other query on these pages narrows (`own_applications`, `Contact.objects.visible_to`, `events ... owner=user`). The erasure summary of a contact under the data-protection module (`gdpr.erase_contact`) has the same unnarrowed counts and is shown to the deleter. Since #854 the facts are computed by `deletions.facts_for` inside the **detail** views, which have `access = READ`, and the dialog markup is written into the page for every reader. A read-only grantee (and a superuser) therefore receives the counts in the page source; the Delete button in the header is not gated either, so the dialog opens, and only the POST is refused (404 from `MANAGE`). ## Impact Another member of the instance (a read-only grantee is enough) learns how many applications, appointments, contacts, references and sent documents other people hold against a shared company or listing: counts only, but exactly the kind of data the threat model keeps per-person. The owner is also misled: "The 3 documents you sent are kept" may count documents they never sent. ## Fix - Narrow the personal rows to the requester (`Application.objects.filter(..., owner=request.user)`, `company.appointments.filter(owner=...)`, `Reference.objects.filter(owner=...)`, `self.object.events.filter(owner=...)`, `self.object.captures.filter(owner=...)`, `RenderedDocument` through the requester's applications) and keep only the record-level counts (postings, visible contacts) unnarrowed; say separately, without a number, that what other people keep of their own stays with them as a copy (which is what `KeepsOthersCopies` does). Do the same for the counts in `gdpr.erase_contact`'s summary. - Gate the Delete/Edit/Merge buttons and the dialog on `sharing.panel(...)["manages"]`, already in the context of both pages, and compute `facts_for` only when `manages`, so a read-only grantee is neither offered them nor sent the counts. Done when `tests/security/test_shared_leaks.py::test_the_pages_show_nothing_of_the_owner_s` also asserts that the delete dialog on a shared company and listing counts none of the owner's applications, appointments or documents, and that a read-only grantee's page has no delete dialog. ## Related #854 (closed) - moved the facts into the detail pages; says nothing about grantees. #217 (closed) - the delete consequences, written before sharing. SEC2-014 - the contact-erasure summary's counts are also covered there.
tiagoagueda added this to the 0.5.0 milestone 2026-10-17 10:38:19 +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#958
No description provided.