PST-006: The candidate-file review keeps answering whether a telephone number is held by another account after the allowance is spent #735
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#735
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-006. Severity: Medium.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/resume/candidate.py(1118-1143)Who can do it, and what they need. Any signed-in account on a shared instance (registration may be open or invite-only; one ordinary account is enough). Needs no knowledge of other accounts. Precondition: the instance holds telephone numbers for other people (any profile or contact number), which is the normal state.
How it works.
_somebody_elsesfirst callsphone_numbers.taken_elsewhere(number)(line 1133) and returns with the row left as ADD when the number is free; only when the number IS held does it callcollision_noticed(line 1137) and, if that raises TooOften, setself.silencedfor the rest of that one draw.silencedis an attribute of the_Plannerbuilt for each call ofcandidate.plan, so it is forgotten on the next page view.account.profile.phone_numbersto /resume/file/ (no throttle on the upload, views_candidate.py:101-134), GET /resume/file/ (the plan is redrawn on every GET, line 95), read the row's outcome. 'Add' = nobody here holds it; 'Refused' = some account on this instance holds it. Two requests per number, unbounded. With up to 20 numbers per file, every number before the first held one is confirmed free and the first held one is identified in one draw.phone_numbers.refusal()returned the same sentence for a free and a held number, whilecandidate.plan()returned ADD for +351912345001/+351912345003 and REFUSED for the other account's +351912345002 on five consecutive draws, and [ADD, REFUSED, REFUSED] for a three-number file. The existing test tests/security/test_candidate_file.py::test_a_file_cannot_ask_about_more_numbers_than_the_form_could only covers the allowance running out inside one draw, so the cross-draw case is not pinned.Impact. Unbounded enumeration of which telephone numbers (mobile or landline, of account holders and of their contacts) are recorded on the instance, bypassing the rate limit the threat model names as the thing that bounds this disclosure. A number sweep confirms that a known person has an account or is somebody's contact here; the threat model itself likens the unbounded form to email enumeration. No record content is read and no write occurs.
Evidence.
Verification (verdict: confirmed; severity after verification: Medium).
The code path is as described, and I reproduced it end to end over HTTP.
_Planner._somebody_elsesnever asks whether the allowance is already spent before it looks the number up. It callsphone_numbers.taken_elsewhere()first and returns with the row left as ADD when the number is free. Only a held number reachescollision_noticed().self.silencedlives on the_Plannerbuilt for eachcandidate.plan()call, so it is forgotten on the next draw. The sanctioned doors (phone_numbers.refusal(),taken_by_asking(),api/routers/companies.py:180) callensure_allowance()BEFORE the lookup, so a spent allowance refuses free and held numbers in the same words. The candidate-file door does not, and its own docstring (lines 1124-1127) promises that behaviour. I looked for a mitigation and found none. The upload POST (views_candidate.py:101-134) has no throttle, and I ran 40 uploads in a row, all 200. The plan is redrawn on every GET (line 95).MIDDLEWAREhas no per-user rate limiter. The existing testtest_a_file_cannot_ask_about_more_numbers_than_the_form_couldonly covers the allowance running out inside one draw. Nothing in docs/THREAT-MODEL.md or SECURITY.md accepts an unbounded form of this. The #90 disclosure is explicitly accepted only in the bounded form (#142). The attacker is an ordinary signed-in account on a multi-account instance, which the model does not trust. I rate it medium (enumeration oracle, rate limit bypassed, no write or content read), in line with the auditor.Corrections from verification. File and line references are correct: candidate.py:1118-1145 (called from :1106) and views_candidate.py:95, 123. Three refinements.
candidate.apply, which redraws the plan) is a second channel: it adds the free number and withholds the held one, as the confirm test above shows.Fix: call
phone_numbers.ensure_allowance(self.user)at the top of_somebody_elses, and on TooOften setsilencedand refuse the row without looking the number up. Add a cross-draw regression test beside the existing within-draw one.Related and NOT verified by me (read from code only): core/importer.py:841-846
_restore_phone_numberscallstaken_elsewhere()directly with no allowance check or charge. It reports the skipped number's last two digits back to the importing account ("already recorded on this instance, not restored"). That would be a further, entirely unthrottled door to the same oracle, worth a separate look.Verification evidence.
Suggested remediation. Make
_somebody_elsesfollow the order every other caller uses: callphone_numbers.ensure_allowance(self.user)(orthrottle.spent) BEFOREtaken_elsewhere, and when the allowance is already spent mark the row REFUSED with the ASKED_TOO_OFTEN sentence whether or not the number is held, i.e. setself.silencedat the start of_numberswhenspent()is non-zero. Keep charging only informative answers if desired, but the refusal must not depend on the lookup once the allowance is gone. Add a regression test that spends the allowance first and asserts a one-number file with a free number and one with a held number draw the same outcome and note; consider also charging the candidate-file upload itself (athrottle.consumeon POST) so that redrawing the review is not free.