SEC2-015: The employer-structure helpers are not narrowed, so a private company's name is shown on a shared company's page #957
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#957
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: 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_dataputsgroup,parent,childrenin the context)src/postulo/templates/jobs/company_detail.html:26-39, 181-203, 351-366(drawsparent.name,group.name,child.name,child.location)src/postulo/jobs/forms.py:291-306(CompanyForm.scope_querysetsoffers every visible company as a parent)src/postulo/jobs/sharing.py:234-252(check_linkonly checks the linking record's audience)Issue
structure.children_ofreturnslist(company.children.all()),parent_ofreturnscompany.parent, andgroup_ofwalkscompany.parentupwards (Company.group); none ofthem 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:
S as a parent (
Company.objects.for_user(self.user)includes shared records), andcheck_link(X, S, B)passes because it asks only whether X's audience (B alone) can seeS. B saves their private company X with
parent = S. A, and every other grantee of S, nowgets
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 andcompanies_counted(S, person, across="group")includes X's id (the postings are re-narrowedby
visible_to, so only the structure leaks, not the postings).plan_sharerequires B to see P, so A shares Ptoo, then revokes P alone. S's page still names P as parent and as the group, since
parent_ofandgroup_ofdo not re-check visibility._within_groupinCompanyListViewis unaffected only because the final queryset is alreadynarrowed;
structure.departments_foris narrowed byDepartment.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.pyanswer for the viewer, leaving the model's own walk(
Company.group,group_members) for the owner-side uses indepartments_forandCompanyListView._within_group:children_ofreturnsCompany.objects.visible_to(person).filter(parent=company);parent_ofreturnscompany.parentonly when it is inCompany.objects.visible_to(person), otherwiseNone;group_ofwalks the chain through visible companies only, stopping at the first invisible parent;in_a_groupandcompanies_countedconsider 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_linkshould also require that the parent's audience can see the child, and it must runon create too (today the form's
cleanskips a new record). Done when a test intests/security/test_shared_leaks.pybuilds case 1 and asserts that S's page, drawn for A andfor 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.