PST-006: The candidate-file review keeps answering whether a telephone number is held by another account after the allowance is spent #735

Closed
opened 2026-10-10 10:55:46 +00:00 by tiagoagueda · 0 comments
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-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.


  • Severity: Medium
  • Category: information-disclosure / missing-rate-limit
  • Location: src/postulo/resume/candidate.py (1118-1143)
  • Found by: ownership review of resume
  • Related: PST-005 (#734), PST-029 (#758)

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.

  1. Postulo keeps telephone numbers unique across the whole instance, so 'can this number be added?' reveals whether some other account holds it. The threat model accepts that disclosure (#90) only because every door that gives it is charged to a per-account allowance, POSTULO_NUMBER_RATE (default 20/h), and once spent 'a held number and a free one are refused in the same words' (#142, #374; phone_numbers.ensure_allowance is called BEFORE the lookup by refusal(), taken_by_asking() and api/routers/companies.py:180).
  2. The candidate-file importer does it the other way round: _somebody_elses first calls phone_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 call collision_noticed (line 1137) and, if that raises TooOften, set self.silenced for the rest of that one draw. silenced is an attribute of the _Planner built for each call of candidate.plan, so it is forgotten on the next page view.
  3. So with the allowance already spent, a free number is still shown as 'Add' and a held number as 'Refused' (with the 'told about a lot of numbers already' note). The attacker spends the allowance once (or waits until a previous sweep spent it), then for each number to probe: POST a JSON candidate file with that one number in account.profile.phone_numbers to /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.
  4. Verified with a scratch test ([scratch test]): with POSTULO_NUMBER_RATE=2/h and the allowance spent, phone_numbers.refusal() returned the same sentence for a free and a held number, while candidate.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.

src/postulo/resume/candidate.py:1129-1143:
        if self.silenced:
            row.outcome = REFUSED
            row.notes.append(self.silenced)
            return
        if not phone_numbers.taken_elsewhere(row.instance.number):
            return
        row.outcome = REFUSED
        try:
            phone_numbers.collision_noticed(self.user)
        except throttle.TooOften as too_often:
            self.silenced = str(...)

Contrast src/postulo/core/phone_numbers.py:251-256 (form path):
        try:
            ensure_allowance(person)
        except throttle.TooOften as too_often:
            return _too_often_sentence(too_often)
        if taken_elsewhere(number, ...):
            return collision_message(person)

src/postulo/resume/views_candidate.py:95: context["review"] = candidate.plan(self.request.user, held) if held else None
src/postulo/resume/views_candidate.py:123: held = candidate.read(upload.read())   # no throttle.consume on this POST

Scratch run output: [('+351912345001', 'add'), ('+351912345002', 'refused'), ('+351912345003', 'add'), ...] repeated 5 times with the allowance spent.

Verification (verdict: confirmed; severity after verification: Medium).

The code path is as described, and I reproduced it end to end over HTTP. _Planner._somebody_elses never asks whether the allowance is already spent before it looks the number up. It calls phone_numbers.taken_elsewhere() first and returns with the row left as ADD when the number is free. Only a held number reaches collision_noticed(). self.silenced lives on the _Planner built for each candidate.plan() call, so it is forgotten on the next draw. The sanctioned doors (phone_numbers.refusal(), taken_by_asking(), api/routers/companies.py:180) call ensure_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). MIDDLEWARE has no per-user rate limiter. The existing test test_a_file_cannot_ask_about_more_numbers_than_the_form_could only 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.

  1. Each draw answers a prefix of free numbers plus the first held number, because every row after the first held one is silenced. With 20 numbers per file (MAX_CONTACT_ROWS), a sweep of mostly free numbers costs about two requests per file, not two per number.
  2. The oracle also needs no spent allowance. Free numbers are never charged, and only held answers spend it, so the free/held bit stays available throughout. Once the allowance is spent, the held answer merely changes its note wording.
  3. The confirm button (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 set silenced and 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_numbers calls taken_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.

Scratch test [scratch test] Setup: POSTULO_NUMBER_RATE=2/h, the attacker's allowance spent, the other account holding +351912345002. Output:
FORM free : You have been told about a lot of numbers already recorded here. That answer is available again in 2 minutes.
FORM held : You have been told about a lot of numbers already recorded here. That answer is available again in 2 minutes.
(form door: identical words for a free and a held number)
Then client.post(reverse('resume:candidate_file'), {'file': one-number JSON}, follow=True), repeated 4 times for each of the three numbers:
FILE +351912345001 add |
FILE +351912345002 refused | You have been told about a lot of numbers already recorded h...
FILE +351912345003 add |
(the same on all 4 rounds; 40 further uploads all returned 200)
Twenty-number file with the held number at index 7:
TWENTY: ['add','add','add','add','add','add','add','refused','refused', ... 'refused']
So the 7 free numbers are confirmed free and the 8th is identified as held in one POST plus one GET. Rows after the first held number are silenced and tell nothing.
Confirm door: after POSTing action=confirm, `user.profile.phone_numbers` == ['+351912345001'], so the free number was added and the held one was not.
Code, candidate.py:1129-1145:
    if self.silenced: ... return
    if not phone_numbers.taken_elsewhere(row.instance.number):
        return
    row.outcome = REFUSED
    try: phone_numbers.collision_noticed(self.user)
    except throttle.TooOften as too_often: self.silenced = ...
compared with phone_numbers.py:251-256 (`ensure_allowance(person)` then `taken_elsewhere(...)`). `ensure_allowance` is never called from candidate.py.

Suggested remediation. Make _somebody_elses follow the order every other caller uses: call phone_numbers.ensure_allowance(self.user) (or throttle.spent) BEFORE taken_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. set self.silenced at the start of _numbers when spent() 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 (a throttle.consume on POST) so that redrawing the review is not free.

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-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. --- - **Severity:** Medium - **Category:** information-disclosure / missing-rate-limit - **Location:** `src/postulo/resume/candidate.py` (1118-1143) - **Found by:** ownership review of resume - **Related:** PST-005 (#734), PST-029 (#758) **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.** 1. Postulo keeps telephone numbers unique across the whole instance, so 'can this number be added?' reveals whether some other account holds it. The threat model accepts that disclosure (#90) only because every door that gives it is charged to a per-account allowance, POSTULO_NUMBER_RATE (default 20/h), and once spent 'a held number and a free one are refused in the same words' (#142, #374; phone_numbers.ensure_allowance is called BEFORE the lookup by refusal(), taken_by_asking() and api/routers/companies.py:180). 2. The candidate-file importer does it the other way round: `_somebody_elses` first calls `phone_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 call `collision_noticed` (line 1137) and, if that raises TooOften, set `self.silenced` for the rest of that one draw. `silenced` is an attribute of the `_Planner` built for each call of `candidate.plan`, so it is forgotten on the next page view. 3. So with the allowance already spent, a free number is still shown as 'Add' and a held number as 'Refused' (with the 'told about a lot of numbers already' note). The attacker spends the allowance once (or waits until a previous sweep spent it), then for each number to probe: POST a JSON candidate file with that one number in `account.profile.phone_numbers` to /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. 4. Verified with a scratch test ([scratch test]): with POSTULO_NUMBER_RATE=2/h and the allowance spent, `phone_numbers.refusal()` returned the same sentence for a free and a held number, while `candidate.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.** ```text src/postulo/resume/candidate.py:1129-1143: if self.silenced: row.outcome = REFUSED row.notes.append(self.silenced) return if not phone_numbers.taken_elsewhere(row.instance.number): return row.outcome = REFUSED try: phone_numbers.collision_noticed(self.user) except throttle.TooOften as too_often: self.silenced = str(...) Contrast src/postulo/core/phone_numbers.py:251-256 (form path): try: ensure_allowance(person) except throttle.TooOften as too_often: return _too_often_sentence(too_often) if taken_elsewhere(number, ...): return collision_message(person) src/postulo/resume/views_candidate.py:95: context["review"] = candidate.plan(self.request.user, held) if held else None src/postulo/resume/views_candidate.py:123: held = candidate.read(upload.read()) # no throttle.consume on this POST Scratch run output: [('+351912345001', 'add'), ('+351912345002', 'refused'), ('+351912345003', 'add'), ...] repeated 5 times with the allowance spent. ``` **Verification** (verdict: confirmed; severity after verification: Medium). The code path is as described, and I reproduced it end to end over HTTP. `_Planner._somebody_elses` never asks whether the allowance is already spent before it looks the number up. It calls `phone_numbers.taken_elsewhere()` first and returns with the row left as ADD when the number is free. Only a held number reaches `collision_noticed()`. `self.silenced` lives on the `_Planner` built for each `candidate.plan()` call, so it is forgotten on the next draw. The sanctioned doors (`phone_numbers.refusal()`, `taken_by_asking()`, `api/routers/companies.py:180`) call `ensure_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). `MIDDLEWARE` has no per-user rate limiter. The existing test `test_a_file_cannot_ask_about_more_numbers_than_the_form_could` only 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. 1. Each draw answers a prefix of free numbers plus the first held number, because every row after the first held one is silenced. With 20 numbers per file (MAX_CONTACT_ROWS), a sweep of mostly free numbers costs about two requests per file, not two per number. 2. The oracle also needs no spent allowance. Free numbers are never charged, and only held answers spend it, so the free/held bit stays available throughout. Once the allowance is spent, the held answer merely changes its note wording. 3. The confirm button (`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 set `silenced` and 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_numbers` calls `taken_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.** ```text Scratch test [scratch test] Setup: POSTULO_NUMBER_RATE=2/h, the attacker's allowance spent, the other account holding +351912345002. Output: FORM free : You have been told about a lot of numbers already recorded here. That answer is available again in 2 minutes. FORM held : You have been told about a lot of numbers already recorded here. That answer is available again in 2 minutes. (form door: identical words for a free and a held number) Then client.post(reverse('resume:candidate_file'), {'file': one-number JSON}, follow=True), repeated 4 times for each of the three numbers: FILE +351912345001 add | FILE +351912345002 refused | You have been told about a lot of numbers already recorded h... FILE +351912345003 add | (the same on all 4 rounds; 40 further uploads all returned 200) Twenty-number file with the held number at index 7: TWENTY: ['add','add','add','add','add','add','add','refused','refused', ... 'refused'] So the 7 free numbers are confirmed free and the 8th is identified as held in one POST plus one GET. Rows after the first held number are silenced and tell nothing. Confirm door: after POSTing action=confirm, `user.profile.phone_numbers` == ['+351912345001'], so the free number was added and the held one was not. Code, candidate.py:1129-1145: if self.silenced: ... return if not phone_numbers.taken_elsewhere(row.instance.number): return row.outcome = REFUSED try: phone_numbers.collision_noticed(self.user) except throttle.TooOften as too_often: self.silenced = ... compared with phone_numbers.py:251-256 (`ensure_allowance(person)` then `taken_elsewhere(...)`). `ensure_allowance` is never called from candidate.py. ``` **Suggested remediation.** Make `_somebody_elses` follow the order every other caller uses: call `phone_numbers.ensure_allowance(self.user)` (or `throttle.spent`) BEFORE `taken_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. set `self.silenced` at the start of `_numbers` when `spent()` 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 (a `throttle.consume` on POST) so that redrawing the review is not free.
tiagoagueda added this to the 0.5.0 milestone 2026-10-10 10:57:20 +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#735
No description provided.