SEC2-018: The logo-fetch allowance is charged to the company's owner, and to a shared "None" bucket for group-owned companies #960

Closed
opened 2026-10-17 10:38:22 +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: Business Logic · Reference: CWE-840 (Business Logic Errors); CWE-770 (Allocation of Resources Without Limits or Throttling); OWASP A04:2021 (Insecure Design)

Location:

  • src/postulo/jobs/logos.py:267-282 (spend_fetch(company.owner) in from_url)
  • src/postulo/jobs/forms.py:434-439 (CompanyForm.apply_logo calls logos.from_url(company, url) with the default spend=True)
  • src/postulo/jobs/views.py:1108-1115, 1269-1275 (LogoFormMixin.form_valid, CompanyLogoActionView._set), :1230 (Find logo, which spends for request.user)
  • src/postulo/core/throttle.py:114-117 (_key: identity = getattr(who, "pk", None) or str(who))

Issue

logos.from_url spends the fetch allowance of company.owner, which before #896 was always
the requester. Now a company may be owned by a group (owner is None) or edited by a
read/write grantee:

def from_url(company, url: str, *, spend: bool = True) -> None:
    if spend:
        spend_fetch(company.owner)
  • For a group-owned company who is None, so _key yields one key shared by every
    logo-address fetch on every group company of the instance, belonging to nobody.
  • For a person-owned company edited by a grantee (CompanyUpdateView, access = WRITE), the
    owner's allowance is spent by someone else. In CompanyLogoActionView._set the requester's
    allowance is spent first (throttle.fetch(request.user)) and then the owner's a second time.

Impact

A read/write grantee can exhaust the owner's fetch allowance (POSTULO_FETCH_RATE), which
also gates link checks on the owner's CV, without the owner doing anything; on an instance
with group-owned companies, any member can exhaust the shared bucket for every group. The rate
limit was introduced by #407 to bound server-side fetches of person-chosen addresses; charging
the wrong account weakens it rather than bypasses it.

Fix

Spend the allowance on the person who pressed the button: give from_url a by argument
(spend_fetch(by)) and pass self.user from CompanyForm.apply_logo. CompanyLogoActionView._set
and the Find logo view already spend for request.user, so they call from_url with
spend=False, which also ends the double charge. Done when tests/test_logos.py (or
tests/security/test_rate_limits.py) asserts that a grantee's logo fetch on a shared company
increments the grantee's counter once and the owner's not at all, and that a group-owned
company's fetch is counted against the requester.

#407 (closed) - introduced the fetch allowance, before sharing. #896 (closed) - introduced group ownership and grantee edits; silent on throttles.

Found on 2026-10-16 in a read-only security review of `postulo` at `56fc9d531`, checked by a second, independent pass. **Severity:** Low · **Category:** Business Logic · **Reference:** CWE-840 (Business Logic Errors); CWE-770 (Allocation of Resources Without Limits or Throttling); OWASP A04:2021 (Insecure Design) **Location:** - `src/postulo/jobs/logos.py:267-282` (`spend_fetch(company.owner)` in `from_url`) - `src/postulo/jobs/forms.py:434-439` (`CompanyForm.apply_logo` calls `logos.from_url(company, url)` with the default `spend=True`) - `src/postulo/jobs/views.py:1108-1115, 1269-1275` (`LogoFormMixin.form_valid`, `CompanyLogoActionView._set`), `:1230` (*Find logo*, which spends for `request.user`) - `src/postulo/core/throttle.py:114-117` (`_key`: `identity = getattr(who, "pk", None) or str(who)`) ## Issue `logos.from_url` spends the fetch allowance of `company.owner`, which before #896 was always the requester. Now a company may be owned by a group (`owner` is `None`) or edited by a read/write grantee: ```python def from_url(company, url: str, *, spend: bool = True) -> None: if spend: spend_fetch(company.owner) ``` - For a group-owned company `who` is `None`, so `_key` yields one key shared by every logo-address fetch on every group company of the instance, belonging to nobody. - For a person-owned company edited by a grantee (`CompanyUpdateView`, `access = WRITE`), the owner's allowance is spent by someone else. In `CompanyLogoActionView._set` the requester's allowance is spent first (`throttle.fetch(request.user)`) and then the owner's a second time. ## Impact A read/write grantee can exhaust the owner's fetch allowance (`POSTULO_FETCH_RATE`), which also gates link checks on the owner's CV, without the owner doing anything; on an instance with group-owned companies, any member can exhaust the shared bucket for every group. The rate limit was introduced by #407 to bound server-side fetches of person-chosen addresses; charging the wrong account weakens it rather than bypasses it. ## Fix Spend the allowance on the person who pressed the button: give `from_url` a `by` argument (`spend_fetch(by)`) and pass `self.user` from `CompanyForm.apply_logo`. `CompanyLogoActionView._set` and the *Find logo* view already spend for `request.user`, so they call `from_url` with `spend=False`, which also ends the double charge. Done when `tests/test_logos.py` (or `tests/security/test_rate_limits.py`) asserts that a grantee's logo fetch on a shared company increments the grantee's counter once and the owner's not at all, and that a group-owned company's fetch is counted against the requester. ## Related #407 (closed) - introduced the fetch allowance, before sharing. #896 (closed) - introduced group ownership and grantee edits; silent on throttles.
tiagoagueda added this to the 0.6.0 milestone 2026-10-17 10:38:23 +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#960
No description provided.