PST-029: Instance-wide telephone-number uniqueness is a bounded cross-account existence oracle #758

Closed
opened 2026-10-10 10:56:05 +00:00 by tiagoagueda · 1 comment
Owner

Found in the security audit of 2026-10-10 (SECURITY-AUDIT-2026-10-10.md), run against main at c30851b from 8 to 10 October 2026, as PST-029. Severity: Low.

The text below is the audit's entry, unchanged apart from links to the other issues it names. The finding was produced by automated review and checked by a second automated pass; re-check it before acting on it.


  • Severity: Low
  • Category: information disclosure (documented trade-off)
  • Location: src/postulo/accounts/forms.py (947-955); src/postulo/core/phone_numbers.py (137-156,218-229,232-257,259-290); src/postulo/api/routers/companies.py (175-182)
  • Found by: REST API, business logic and imports, ownership review of accounts
  • Related: PST-005 (#734), PST-006 (#735)

Who can do it, and what they need. Any signed-in account typing numbers on Your details.

How it works.

Typing a number another account holds is refused with 'That number is already recorded on this instance ... may belong to somebody else's records'. The answer is charged against POSTULO_NUMBER_RATE (20/h per account, settings/base.py:451); once spent every new or changed number is refused with the too-often sentence, so a sweep learns at most 20 numbers per hour per account.

Impact. Confirms whether a given telephone number belongs to someone on the instance. The threat model and #90/#142 state and accept this; the rate limit bounds it.

Evidence.

phone_numbers.py:44-47 ALREADY_IN_USE = _("That number is already recorded on this instance. Telephone numbers are kept unique across every account here, so it may belong to somebody else's records.")
phone_numbers.py:253-255: if taken_elsewhere(...): return collision_message(person)

Verification (verdict: confirmed; severity after verification: Low; first-pass claim: Informational). Cited lines are right for the form paths (note ProfileForm.clean_phone is dormant by default; the default path is BasePhoneNumberFormSet.clean at phone_numbers.py:472 via refusal at :232). The 'bounded to 20/h' claim does not hold for the candidate-file review. Real location of the bypass: src/postulo/resume/candidate.py:1118-1145 (_somebody_elses), reached from _numbers (about line 1106). Attacker: any signed-in member. Precondition: the account's POSTULO_NUMBER_RATE allowance is spent, which any sweep does by itself within the first 20 hits. Path: upload candidate JSON files (up to MAX_CONTACT_ROWS = 20 numbers each; the review marks numbers before the first held one as 'add' and the first held one as refused). Effect: unbounded confirmation of which telephone numbers are recorded anywhere on the instance (profiles and contacts, any owner), without naming the account. Remediation: call phone_numbers.ensure_allowance(self.user) before taken_elsewhere in _somebody_elses (catching TooOften into self.silenced), and add a security test where the allowance is spent before the file is read. …

Suggested remediation. None required; keep the throttle. If the disclosure ever becomes unacceptable, the uniqueness could be scoped per owner, as postal addresses already are.

Also covered by this issue.

  • Phone-number existence oracle charges only positive answers; negative answers are free and unbounded (acknowledged design, #90/#142) (src/postulo/core/phone_numbers.py (137-156,218-229,232-257,259-290); Low). Cross-account existence of a telephone number is disclosed, which the threat model accepts explicitly ('the telephone numbers make the opposite trade knowingly, and the message there says so out loud') and bounds at 20 informative answers per hour per account. …
  • Instance-wide telephone-number existence oracle is reachable through POST /companies/{pk}/contacts (src/postulo/api/routers/companies.py (175-182); Informational). Confirms which numbers are held by somebody on the instance. The threat model and collision_message document this trade knowingly (#90, #142) and bound it; recorded here so the API path is known to be covered by the same limit.
Found in the security audit of 2026-10-10 (`SECURITY-AUDIT-2026-10-10.md`), run against `main` at `c30851b` from 8 to 10 October 2026, as **PST-029**. Severity: **Low**. The text below is the audit's entry, unchanged apart from links to the other issues it names. The finding was produced by automated review and checked by a second automated pass; re-check it before acting on it. --- - **Severity:** Low - **Category:** information disclosure (documented trade-off) - **Location:** `src/postulo/accounts/forms.py` (947-955); `src/postulo/core/phone_numbers.py` (137-156,218-229,232-257,259-290); `src/postulo/api/routers/companies.py` (175-182) - **Found by:** REST API, business logic and imports, ownership review of accounts - **Related:** PST-005 (#734), PST-006 (#735) **Who can do it, and what they need.** Any signed-in account typing numbers on Your details. **How it works.** Typing a number another account holds is refused with 'That number is already recorded on this instance ... may belong to somebody else's records'. The answer is charged against POSTULO_NUMBER_RATE (20/h per account, settings/base.py:451); once spent every new or changed number is refused with the too-often sentence, so a sweep learns at most 20 numbers per hour per account. **Impact.** Confirms whether a given telephone number belongs to someone on the instance. The threat model and #90/#142 state and accept this; the rate limit bounds it. **Evidence.** ```text phone_numbers.py:44-47 ALREADY_IN_USE = _("That number is already recorded on this instance. Telephone numbers are kept unique across every account here, so it may belong to somebody else's records.") phone_numbers.py:253-255: if taken_elsewhere(...): return collision_message(person) ``` **Verification** (verdict: confirmed; severity after verification: Low; first-pass claim: Informational). Cited lines are right for the form paths (note ProfileForm.clean_phone is dormant by default; the default path is BasePhoneNumberFormSet.clean at phone_numbers.py:472 via refusal at :232). The 'bounded to 20/h' claim does not hold for the candidate-file review. Real location of the bypass: src/postulo/resume/candidate.py:1118-1145 (`_somebody_elses`), reached from `_numbers` (about line 1106). Attacker: any signed-in member. Precondition: the account's POSTULO_NUMBER_RATE allowance is spent, which any sweep does by itself within the first 20 hits. Path: upload candidate JSON files (up to MAX_CONTACT_ROWS = 20 numbers each; the review marks numbers before the first held one as 'add' and the first held one as refused). Effect: unbounded confirmation of which telephone numbers are recorded anywhere on the instance (profiles and contacts, any owner), without naming the account. Remediation: call `phone_numbers.ensure_allowance(self.user)` before `taken_elsewhere` in `_somebody_elses` (catching TooOften into `self.silenced`), and add a security test where the allowance is spent before the file is read. … **Suggested remediation.** None required; keep the throttle. If the disclosure ever becomes unacceptable, the uniqueness could be scoped per owner, as postal addresses already are. **Also covered by this issue.** - **Phone-number existence oracle charges only positive answers; negative answers are free and unbounded (acknowledged design, #90/#142)** (`src/postulo/core/phone_numbers.py` (137-156,218-229,232-257,259-290); Low). Cross-account existence of a telephone number is disclosed, which the threat model accepts explicitly ('the telephone numbers make the opposite trade knowingly, and the message there says so out loud') and bounds at 20 informative answers per hour per account. … - **Instance-wide telephone-number existence oracle is reachable through POST /companies/{pk}/contacts** (`src/postulo/api/routers/companies.py` (175-182); Informational). Confirms which numbers are held by somebody on the instance. The threat model and `collision_message` document this trade knowingly (#90, #142) and bound it; recorded here so the API path is known to be covered by the same limit.
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:37 +00:00
Author
Owner

No change, by the audit's own verdict: the cross-account phone-number check is the documented trade (threat model, #90/#142), bounded by POSTULO_NUMBER_RATE (20 informative answers an hour per account). Per-owner uniqueness stays the option if the disclosure ever becomes unacceptable.

No change, by the audit's own verdict: the cross-account phone-number check is the documented trade (threat model, #90/#142), bounded by POSTULO_NUMBER_RATE (20 informative answers an hour per account). Per-owner uniqueness stays the option if the disclosure ever becomes unacceptable.
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#758
No description provided.