SEC2-007: PATCH /companies/{id} lets a read/write grantee probe the owner's other company names through the rename clash check #949
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#949
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: Low · Category: Data Exposure · Reference: CWE-203 (Observable Discrepancy); OWASP A01:2021 (Broken Access Control)
Location:
src/postulo/api/routers/companies.py:203-212src/postulo/api/routers/companies.py:228-229src/postulo/jobs/forms.py:505-527(the page makes the same check)src/postulo/core/throttle.py:120-152(the per-account allowance pattern a bound would reuse)Issue
A company shared read/write may be renamed by the grantee. The clash check runs against the
owner's whole set of companies, which the grantee cannot otherwise see:
A
PATCHwith anametherefore answers 422 when the owner holds a company of that nameand 200 (renaming the shared record) when they do not. The grantee can rename it back
afterwards, so the probe costs two calls per guess, bounded only by the API allowance
(600/h per token, 3000/h per account). The web form (
CompanyForm.clean_name, withprincipal()returningself.instance.principal) makes the same check with the samesentence, so this is a property of the design and not of the API alone, but the API is where
it is cheap to repeat. The threat model's promise is that another account's data is seen
only where granted; a read/write grant on one company is not a grant to the names of the
others. The project accepted a comparable oracle knowingly for telephone numbers (#758) and
bounded it; it has not said anything about company names.
Impact
A person or group member holding a read/write share on a single company learns, name by name,
which other employers the sharer is tracking: a picture of somebody else's job search. Needs
a share, so the prober is somebody the owner trusted with one record. Each 200 answer leaves
a rename in the record's trail that the owner can see, so the probe is not silent.
Fix
The check cannot simply be dropped: the per-owner uniqueness constraint needs it. #896's
written decision is that a read/write grantee edits the record's own fields, and the name is
one of them, so the fix is a choice for the owner between three options:
patch_company,refuse
nameunless the caller is inCompany.objects.manageable_by(request.auth.owner)forthat company (same 404 shape as the rest of the API, or a 403 "only the owner renames it"),
and apply the same rule in
CompanyForm.clean_namefor an instance whoseprincipalis notthe form's user. Simplest and complete.
refused renames of a company the caller does not own against a per-account allowance (the
POSTULO_NUMBER_RATEpattern,core/throttle.py:120-152), and record the accepted residue inthe threat model.
Done when, for (a),
tests/security/test_sharing_sweep.pyhas a case where a write granteesends a
PATCHnaming a company the owner holds privately and the answer is identical tonaming one the owner does not; for (b), when a test in
tests/security/test_rate_limits.pysees the N+1th refused rename answered 429.
Related
#546 (closed) and #438 (closed) - introduced this check for the API. #896 (closed) - introduced the owner scoping and decided that a read/write grantee edits the record's fields. #758 (closed) - the comparable, knowingly bounded oracle on telephone numbers.