SEC2-031: The company merge preview lists every member's appointments at the other company, not only the viewer's #973

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

Location:

  • src/postulo/jobs/merging.py:428 (_moved(_("Appointments"), other.appointments.all(), str) in plan_companies)
  • src/postulo/jobs/merging.py:399, 416-427 (mine = _seen_by(viewer); the career rows beside it are other.<relation>.filter(**mine))
  • src/postulo/applications/models.py:992-994 (Appointment.__str__: the company's name and the meeting's date and time)
  • src/postulo/jobs/views.py:1285-1313, 1370 (MergeView over manageable_by(self.request.user), so any member of the owning group may merge two of the group's companies)

Issue

plan_companies builds the list of what a merge would move. The person-owned rows next to it (career, education, certifications, honours, memberships, courses) are narrowed with filter(**mine), where mine comes from _seen_by(viewer); the appointments row is other.appointments.all(), named by str(), which is the company's name and the meeting's date and time. On a group-owned employment-service company, the member who opens the merge preview is shown when every other member meets their adviser. Moving all of them (merging.py:584, other.appointments.update(company=kept)) is right; naming them to the viewer is not. (The delete confirmation at jobs/views.py:1413 counts company.appointments.count() across owners too, but a count of what a cascade will remove is the honest consequence and carries no detail.)

Impact

A group member learns the dates and times of the other members' appointments at a shared employment service: existence and timing only, not the notes or the place.

Fix

_moved(_("Appointments"), other.appointments.filter(**mine), str) (the viewer's own), or keep the whole set but name it by count only. Done when: a test in tests/test_merging.py (or tests/security/test_shared_leaks.py) gives two members of a group an appointment each at a group-owned company and asserts the merge plan names only the viewer's.

Found on 2026-10-16 in a read-only security review of `postulo` at `56fc9d531`, checked by a second, independent pass. **Severity:** Low · **Category:** Data Exposure · **Reference:** CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor); OWASP A01:2021 (Broken Access Control) **Location:** - `src/postulo/jobs/merging.py:428` (`_moved(_("Appointments"), other.appointments.all(), str)` in `plan_companies`) - `src/postulo/jobs/merging.py:399, 416-427` (`mine = _seen_by(viewer)`; the career rows beside it are `other.<relation>.filter(**mine)`) - `src/postulo/applications/models.py:992-994` (`Appointment.__str__`: the company's name and the meeting's date and time) - `src/postulo/jobs/views.py:1285-1313, 1370` (`MergeView` over `manageable_by(self.request.user)`, so any member of the owning group may merge two of the group's companies) ## Issue `plan_companies` builds the list of what a merge would move. The person-owned rows next to it (career, education, certifications, honours, memberships, courses) are narrowed with `filter(**mine)`, where `mine` comes from `_seen_by(viewer)`; the appointments row is `other.appointments.all()`, named by `str()`, which is the company's name and the meeting's date and time. On a group-owned employment-service company, the member who opens the merge preview is shown when every other member meets their adviser. Moving all of them (`merging.py:584`, `other.appointments.update(company=kept)`) is right; naming them to the viewer is not. (The delete confirmation at `jobs/views.py:1413` counts `company.appointments.count()` across owners too, but a count of what a cascade will remove is the honest consequence and carries no detail.) ## Impact A group member learns the dates and times of the other members' appointments at a shared employment service: existence and timing only, not the notes or the place. ## Fix `_moved(_("Appointments"), other.appointments.filter(**mine), str)` (the viewer's own), or keep the whole set but name it by count only. Done when: a test in `tests/test_merging.py` (or `tests/security/test_shared_leaks.py`) gives two members of a group an appointment each at a group-owned company and asserts the merge plan names only the viewer's.
tiagoagueda added this to the 0.6.0 milestone 2026-10-17 10:38:52 +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#973
No description provided.