SEC2-007: PATCH /companies/{id} lets a read/write grantee probe the owner's other company names through the rename clash check #949

Closed
opened 2026-10-17 10:37:55 +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-203 (Observable Discrepancy); OWASP A01:2021 (Broken Access Control)

Location:

  • src/postulo/api/routers/companies.py:203-212
  • src/postulo/api/routers/companies.py:228-229
  • src/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:

def _name_or_refusal(request, company: Company, name: str) -> str:
    ...
    # Among the company's own owner's (#896): a person's or a group's.
    clash = Company.objects.owned_by(company.principal).filter(name_key=slugs.name_key(name))
    if clash.exclude(pk=company.pk).exists():
        raise HttpError(422, _("You already have a company with that name."))
    return name

A PATCH with a name therefore answers 422 when the owner holds a company of that name
and 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, with
principal() returning self.instance.principal) makes the same check with the same
sentence, 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:

  • (a) amend #896 and the threat model so that renaming is an owner's action: in patch_company,
    refuse name unless the caller is in Company.objects.manageable_by(request.auth.owner) for
    that 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_name for an instance whose principal is not
    the form's user. Simplest and complete.
  • (b) keep renames for grantees and bound the oracle as #758 bounds the telephone one: count
    refused renames of a company the caller does not own against a per-account allowance (the
    POSTULO_NUMBER_RATE pattern, core/throttle.py:120-152), and record the accepted residue in
    the threat model.
  • (c) record it as accepted with no code change.

Done when, for (a), tests/security/test_sharing_sweep.py has a case where a write grantee
sends a PATCH naming a company the owner holds privately and the answer is identical to
naming one the owner does not; for (b), when a test in tests/security/test_rate_limits.py
sees the N+1th refused rename answered 429.

#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.

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-203 (Observable Discrepancy); OWASP A01:2021 (Broken Access Control) **Location:** - `src/postulo/api/routers/companies.py:203-212` - `src/postulo/api/routers/companies.py:228-229` - `src/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: ```python def _name_or_refusal(request, company: Company, name: str) -> str: ... # Among the company's own owner's (#896): a person's or a group's. clash = Company.objects.owned_by(company.principal).filter(name_key=slugs.name_key(name)) if clash.exclude(pk=company.pk).exists(): raise HttpError(422, _("You already have a company with that name.")) return name ``` A `PATCH` with a `name` therefore answers 422 when the owner holds a company of that name and 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`, with `principal()` returning `self.instance.principal`) makes the same check with the same sentence, 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: - (a) amend #896 and the threat model so that renaming is an owner's action: in `patch_company`, refuse `name` unless the caller is in `Company.objects.manageable_by(request.auth.owner)` for that 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_name` for an instance whose `principal` is not the form's user. Simplest and complete. - (b) keep renames for grantees and bound the oracle as #758 bounds the telephone one: count refused renames of a company the caller does not own against a per-account allowance (the `POSTULO_NUMBER_RATE` pattern, `core/throttle.py:120-152`), and record the accepted residue in the threat model. - (c) record it as accepted with no code change. Done when, for (a), `tests/security/test_sharing_sweep.py` has a case where a write grantee sends a `PATCH` naming a company the owner holds privately and the answer is identical to naming one the owner does not; for (b), when a test in `tests/security/test_rate_limits.py` sees 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.
tiagoagueda added this to the 0.6.0 milestone 2026-10-17 10:37:56 +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#949
No description provided.