SEC2-024: The account archive exports every appointment at a shared or group-owned company, not only the exporter's own #966
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#966
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: 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(thePrefetch("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: writesnotes,outcome_notes,place,uid,contact_idand the nested reminder)src/postulo/core/export.py:1080(counts()usesAppointment.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_documentreads 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:Appointmentis anOwnedModel(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 wholeappointmentsset is then written. On a company owned by a group, or shared with the exporter, that set holds the other members' appointments too.counts()countsAppointment.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 throughwith_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. Neithertests/security/test_shared_leaks.pynortest_sharing_sweep.pyreads the archive for appointments, andtests/test_export.pyholdscounts()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:
(
Appointment.objects.for_user(user)does equally well.) A related completeness gap, not a leak: theCompanyLogolookup atexport.py:1236filters oncompany__owner=userand so misses the logos of group-owned companies.Done when: the
worldfixture intests/security/test_shared_leaks.pygains anAppointment.objects.create(owner=owner, company=company, starts_at=..., notes="OWNER-APPOINTMENT-NOTE")with that string added toSECRETS, so the existing export test and every other surface test in the file catch it; plus a test (there or intests/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'sbuild_documentcarries only their own appointment and thatcounts()["appointments"]equals the number written.Related
#896 (closed) and #888 (closed) - the sharing and group-ownership features through which a company's
appointmentsset comes to hold more than one person's rows; neither mentions the archive.