PST-029: Instance-wide telephone-number uniqueness is a bounded cross-account existence oracle #758
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#758
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 in the security audit of 2026-10-10 (
SECURITY-AUDIT-2026-10-10.md), run againstmainatc30851bfrom 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.
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)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.
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: callphone_numbers.ensure_allowance(self.user)beforetaken_elsewherein_somebody_elses(catching TooOften intoself.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.
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. …src/postulo/api/routers/companies.py(175-182); Informational). Confirms which numbers are held by somebody on the instance. The threat model andcollision_messagedocument this trade knowingly (#90, #142) and bound it; recorded here so the API path is known to be covered by the same limit.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.