SEC2-024: The account archive exports every appointment at a shared or group-owned company, not only the exporter's own #966

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

Location:

  • src/postulo/core/export.py:1218-1221 (the Prefetch("appointments", ...) with no owner filter)
  • src/postulo/core/export.py:1283 ("appointments": [_appointment(a) for a in company.appointments.all()])
  • src/postulo/core/export.py:969-979 (_appointment: writes notes, outcome_notes, place, uid, contact_id and the nested reminder)
  • src/postulo/core/export.py:1080 (counts() uses Appointment.objects.for_user(user), so the preview count and the document disagree)
  • src/postulo/core/importer.py:1680-1730 (the importing side then recreates those rows under the importer)

Issue

build_document reads the companies the exporter may see (their own, their groups' and the ones only shared with them that their records point at) and prefetches most children through owner-scoped querysets (Contact.objects.visible_to(user), ListingEvent.objects.filter(owner=user), Application.objects.filter(owner=user)). The appointments are the exception:

Prefetch(
    "appointments",
    queryset=Appointment.objects.select_related("reminder"),
),
...
"appointments": [_appointment(a) for a in company.appointments.all()],

Appointment is an OwnedModel (applications/models.py:925): each row belongs to one person and holds that person's preparation notes, what was agreed with the adviser, the place, the time and the adviser's contact. _pointed_at(user) deliberately pulls in a company because the exporter has an appointment there (Q(appointments__owner=user)), and the company's whole appointments set is then written. On a company owned by a group, or shared with the exporter, that set holds the other members' appointments too. counts() counts Appointment.objects.for_user(user), so the page announces fewer appointments than the file carries.

No other per-person relation leaks through this prefetch set: notes come through with_notes(user), listing statuses through with_status(user), history and applications through owner filters, and interviews, offers, reminders and sent documents hang off the owner-filtered applications; kind_files.build_document (core/kind_files.py:287-311) writes no appointments. Neither tests/security/test_shared_leaks.py nor test_sharing_sweep.py reads the archive for appointments, and tests/test_export.py holds counts() to the document only for a single account.

Impact

A group member, or anyone a public-employment-service company is shared with, downloads an archive carrying every other member's meetings with their adviser at that company: when, where, the preparation notes and "what was agreed", which are the kind of notes the threat model promises nobody else sees ("A person's notes ... are theirs whatever is shared"). Restoring the archive (import_data) then recreates those rows as the importer's own.

Fix

Scope the prefetch to the exporter, as the other children are:

Prefetch(
    "appointments",
    queryset=Appointment.objects.filter(owner=user).select_related("reminder"),
),

(Appointment.objects.for_user(user) does equally well.) A related completeness gap, not a leak: the CompanyLogo lookup at export.py:1236 filters on company__owner=user and so misses the logos of group-owned companies.

Done when: the world fixture in tests/security/test_shared_leaks.py gains an Appointment.objects.create(owner=owner, company=company, starts_at=..., notes="OWNER-APPOINTMENT-NOTE") with that string added to SECRETS, so the existing export test and every other surface test in the file catch it; plus a test (there or in tests/test_export.py) that gives two members of one group a group-owned employment-service company, records an appointment for each, and asserts that each member's build_document carries only their own appointment and that counts()["appointments"] equals the number written.

#896 (closed) and #888 (closed) - the sharing and group-ownership features through which a company's appointments set comes to hold more than one person's rows; neither mentions the archive.

Found on 2026-10-16 in a read-only security review of `postulo` at `56fc9d531`, checked by a second, independent pass. **Severity:** High · **Category:** Data Exposure · **Reference:** CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor); CWE-862 (Missing Authorization); OWASP A01:2021 (Broken Access Control) **Location:** - `src/postulo/core/export.py:1218-1221` (the `Prefetch("appointments", ...)` with no owner filter) - `src/postulo/core/export.py:1283` (`"appointments": [_appointment(a) for a in company.appointments.all()]`) - `src/postulo/core/export.py:969-979` (`_appointment`: writes `notes`, `outcome_notes`, `place`, `uid`, `contact_id` and the nested reminder) - `src/postulo/core/export.py:1080` (`counts()` uses `Appointment.objects.for_user(user)`, so the preview count and the document disagree) - `src/postulo/core/importer.py:1680-1730` (the importing side then recreates those rows under the importer) ## Issue `build_document` reads the companies the exporter may see (their own, their groups' and the ones only shared with them that their records point at) and prefetches most children through owner-scoped querysets (`Contact.objects.visible_to(user)`, `ListingEvent.objects.filter(owner=user)`, `Application.objects.filter(owner=user)`). The appointments are the exception: ```python Prefetch( "appointments", queryset=Appointment.objects.select_related("reminder"), ), ... "appointments": [_appointment(a) for a in company.appointments.all()], ``` `Appointment` is an `OwnedModel` (`applications/models.py:925`): each row belongs to one person and holds that person's preparation notes, what was agreed with the adviser, the place, the time and the adviser's contact. `_pointed_at(user)` deliberately pulls in a company because the exporter has an appointment there (`Q(appointments__owner=user)`), and the company's whole `appointments` set is then written. On a company owned by a group, or shared with the exporter, that set holds the other members' appointments too. `counts()` counts `Appointment.objects.for_user(user)`, so the page announces fewer appointments than the file carries. No other per-person relation leaks through this prefetch set: notes come through `with_notes(user)`, listing statuses through `with_status(user)`, history and applications through owner filters, and interviews, offers, reminders and sent documents hang off the owner-filtered applications; `kind_files.build_document` (`core/kind_files.py:287-311`) writes no appointments. Neither `tests/security/test_shared_leaks.py` nor `test_sharing_sweep.py` reads the archive for appointments, and `tests/test_export.py` holds `counts()` to the document only for a single account. ## Impact A group member, or anyone a public-employment-service company is shared with, downloads an archive carrying every other member's meetings with their adviser at that company: when, where, the preparation notes and "what was agreed", which are the kind of notes the threat model promises nobody else sees ("A person's notes ... are theirs whatever is shared"). Restoring the archive (`import_data`) then recreates those rows as the importer's own. ## Fix Scope the prefetch to the exporter, as the other children are: ```python Prefetch( "appointments", queryset=Appointment.objects.filter(owner=user).select_related("reminder"), ), ``` (`Appointment.objects.for_user(user)` does equally well.) A related completeness gap, not a leak: the `CompanyLogo` lookup at `export.py:1236` filters on `company__owner=user` and so misses the logos of group-owned companies. Done when: the `world` fixture in `tests/security/test_shared_leaks.py` gains an `Appointment.objects.create(owner=owner, company=company, starts_at=..., notes="OWNER-APPOINTMENT-NOTE")` with that string added to `SECRETS`, so the existing export test and every other surface test in the file catch it; plus a test (there or in `tests/test_export.py`) that gives two members of one group a group-owned employment-service company, records an appointment for each, and asserts that each member's `build_document` carries only their own appointment and that `counts()["appointments"]` equals the number written. ## Related #896 (closed) and #888 (closed) - the sharing and group-ownership features through which a company's `appointments` set comes to hold more than one person's rows; neither mentions the archive.
tiagoagueda added this to the 0.5.0 milestone 2026-10-17 10:38:36 +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#966
No description provided.