SEC2-015: The employer-structure helpers are not narrowed, so a private company's name is shown on a shared company's page #957

Closed
opened 2026-10-17 10:38:16 +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/structure.py:43-57 (parent_of, children_of), :80-95 (group_of, in_a_group), :98-110 (companies_counted)
  • src/postulo/jobs/models.py:798-836 (Company.group, group_members, descendants)
  • src/postulo/jobs/views.py:888-890 (CompanyDetailView.get_context_data puts group, parent, children in the context)
  • src/postulo/templates/jobs/company_detail.html:26-39, 181-203, 351-366 (draws parent.name, group.name, child.name, child.location)
  • src/postulo/jobs/forms.py:291-306 (CompanyForm.scope_querysets offers every visible company as a parent)
  • src/postulo/jobs/sharing.py:234-252 (check_link only checks the linking record's audience)

Issue

structure.children_of returns list(company.children.all()), parent_of returns
company.parent, and group_of walks company.parent upwards (Company.group); none of
them asks whether the person can see those companies. Before #896 every company in a tree had
one owner, so this was safe. Now a company visible to a person may have links to companies
that are not:

  1. Person B is granted (read-only is enough) owner A's company S. B's own company form offers
    S as a parent (Company.objects.for_user(self.user) includes shared records), and
    check_link(X, S, B) passes because it asks only whether X's audience (B alone) can see
    S. B saves their private company X with parent = S. A, and every other grantee of S, now
    gets children_of(S, …) == [X], and S's page lists X with its name and town
    (child.location), linking to a page that answers 404. in_a_group(S) turns true and
    companies_counted(S, person, across="group") includes X's id (the postings are re-narrowed
    by visible_to, so only the structure leaks, not the postings).
  2. A shares S (parent P, also A's) with B; plan_share requires B to see P, so A shares P
    too, then revokes P alone. S's page still names P as parent and as the group, since
    parent_of and group_of do not re-check visibility.

_within_group in CompanyListView is unaffected only because the final queryset is already
narrowed; structure.departments_for is narrowed by Department.objects.for_user.

Impact

Another member of the instance learns the name and town of a company a person keeps private
(a prospective employer, which is what the application tracks), through the ordinary company
page of a record that was legitimately shared with them. A read-only grant suffices in case 1,
and the leak reaches every grantee of S, not only its owner. No notes, postings or
applications leak.

Fix

Make the helpers in structure.py answer for the viewer, leaving the model's own walk
(Company.group, group_members) for the owner-side uses in departments_for and
CompanyListView._within_group:

  • children_of returns Company.objects.visible_to(person).filter(parent=company);
  • parent_of returns company.parent only when it is in Company.objects.visible_to(person), otherwise None;
  • group_of walks the chain through visible companies only, stopping at the first invisible parent;
  • in_a_group and companies_counted consider visible members only (otherwise the "Across the … group" link appears with nothing visible behind it).

Close case 1 at save time as well: since a parent link is visible to the parent's audience,
check_link should also require that the parent's audience can see the child, and it must run
on create too (today the form's clean skips a new record). Done when a test in
tests/security/test_shared_leaks.py builds case 1 and asserts that S's page, drawn for A and
for a third grantee, contains neither X's name nor its town (and that the link is refused at
save), and that after revoking P in case 2, S's page no longer names P.

#896 (closed) - introduced per-record sharing; the structure helpers predate it.

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/structure.py:43-57` (`parent_of`, `children_of`), `:80-95` (`group_of`, `in_a_group`), `:98-110` (`companies_counted`) - `src/postulo/jobs/models.py:798-836` (`Company.group`, `group_members`, `descendants`) - `src/postulo/jobs/views.py:888-890` (`CompanyDetailView.get_context_data` puts `group`, `parent`, `children` in the context) - `src/postulo/templates/jobs/company_detail.html:26-39, 181-203, 351-366` (draws `parent.name`, `group.name`, `child.name`, `child.location`) - `src/postulo/jobs/forms.py:291-306` (`CompanyForm.scope_querysets` offers every visible company as a parent) - `src/postulo/jobs/sharing.py:234-252` (`check_link` only checks the *linking* record's audience) ## Issue `structure.children_of` returns `list(company.children.all())`, `parent_of` returns `company.parent`, and `group_of` walks `company.parent` upwards (`Company.group`); none of them asks whether the person can see those companies. Before #896 every company in a tree had one owner, so this was safe. Now a company visible to a person may have links to companies that are not: 1. Person B is granted (read-only is enough) owner A's company S. B's own company form offers S as a parent (`Company.objects.for_user(self.user)` includes shared records), and `check_link(X, S, B)` passes because it asks only whether *X's* audience (B alone) can see S. B saves their private company X with `parent = S`. A, and every other grantee of S, now gets `children_of(S, …) == [X]`, and S's page lists X with its name and town (`child.location`), linking to a page that answers 404. `in_a_group(S)` turns true and `companies_counted(S, person, across="group")` includes X's id (the postings are re-narrowed by `visible_to`, so only the structure leaks, not the postings). 2. A shares S (parent P, also A's) with B; `plan_share` requires B to see P, so A shares P too, then revokes P alone. S's page still names P as parent and as the group, since `parent_of` and `group_of` do not re-check visibility. `_within_group` in `CompanyListView` is unaffected only because the final queryset is already narrowed; `structure.departments_for` is narrowed by `Department.objects.for_user`. ## Impact Another member of the instance learns the name and town of a company a person keeps private (a prospective employer, which is what the application tracks), through the ordinary company page of a record that was legitimately shared with them. A read-only grant suffices in case 1, and the leak reaches every grantee of S, not only its owner. No notes, postings or applications leak. ## Fix Make the helpers in `structure.py` answer for the viewer, leaving the model's own walk (`Company.group`, `group_members`) for the owner-side uses in `departments_for` and `CompanyListView._within_group`: - `children_of` returns `Company.objects.visible_to(person).filter(parent=company)`; - `parent_of` returns `company.parent` only when it is in `Company.objects.visible_to(person)`, otherwise `None`; - `group_of` walks the chain through visible companies only, stopping at the first invisible parent; - `in_a_group` and `companies_counted` consider visible members only (otherwise the "Across the … group" link appears with nothing visible behind it). Close case 1 at save time as well: since a parent link is visible to the parent's audience, `check_link` should also require that the parent's audience can see the child, and it must run on create too (today the form's `clean` skips a new record). Done when a test in `tests/security/test_shared_leaks.py` builds case 1 and asserts that S's page, drawn for A and for a third grantee, contains neither X's name nor its town (and that the link is refused at save), and that after revoking P in case 2, S's page no longer names P. ## Related #896 (closed) - introduced per-record sharing; the structure helpers predate it.
tiagoagueda added this to the 0.5.0 milestone 2026-10-17 10:38:17 +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#957
No description provided.