Telephone numbers: the collision limit changes the wording but not the answer, and contact pages share one bucket #374
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#374
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 on 2026-09-29 in a read-only review of postulo at
95348908d, by reading every caller ofphone_numbers.collision_messageand running the formset against a spent allowance.Related to #333.
Checked again on 2026-10-01 against
mainat0a5cad587: still present. The code is now atcore/phone_numbers.py:326-327(the check inBasePhoneNumberFormSet.clean),core/phone_numbers.py:305-308(the comment onasked_by),accounts/forms.py:407-414andjobs/forms.py:630-637(the twoclean_phonemethods);core/mixins.py,core/throttle.py,api/routers/companies.py,resume/candidate.pyand the test have not moved.The limit from #142 is meant to stop one account from learning which telephone numbers are recorded on the instance. On the forms and the API it only changes the words. Once the allowance is spent, a number somebody holds is still refused and a free one is still accepted, so the refusal itself remains the answer. The CV and letter file importer already handles this correctly; the forms and the API do not. Separately, the contact pages never say who is asking, so all accounts share one allowance there.
core/phone_numbers.py:349-350(BasePhoneNumberFormSet.clean):if taken_elsewhere(...): form.add_error("number", collision_message(self.asked_by)). The error is added whatever the message says.accounts/forms.py:405-413andjobs/forms.py:633-640(clean_phoneon the profile and contact forms) andapi/routers/companies.py:123-126(add_contact, 422): the same shape.core/mixins.py:145-160(PhoneNumbersMixin.get_phone_numbers, used by the contact create and update views) builds the formset withoutasked_by, socollision_message(None)reachesthrottle.consumewithwho=None, andcore/throttle.py:110keys it onstr(None).core/phone_numbers.py:328-331says that with noasked_by"the limit has nobody to charge and the honest sentence is given". In fact the sharedNonebucket is charged.resume/candidate.py:840-866(_somebody_elses) refuses every further number in the same words once the allowance is spent, "because telling them apart is the answer".POSTULO_NUMBER_RATEspent, a two-row formset holding one number that another account has and one that nobody has still flags only the first. The flag now reads "You have been told about a lot of numbers already recorded here…" instead of "already recorded". A formset accepts up to 1,000 rows per request, so each request tests hundreds of numbers, and spending the allowance does not stop it. On the contact pages, one person's collisions spend the instance-wideNoneallowance, and everyone else is then shown the too-often sentence on their own contact forms.POST /api/companies/{pk}/contactswith a token. It reveals only that some account on the instance holds a number, not whose it is. That is the disclosure #142 set out to limit.postulo.wiki/Tracking-applications.mdpromises that "after a score of them in an hour Postulo says when the answer will be available again instead", and the sweep #142 describes still works. The shared bucket also means one person can make every other account see the throttling message.throttle.spent(action, who, rate).clean_phonemethods and the API, refuse every new or changed number withASKED_TOO_OFTENonce the allowance is spent, taken or not, before callingtaken_elsewhere. That is the rule_somebody_elsesalready follows. The API should answer 429 withRetry-After.asked_by=self.request.userfromPhoneNumbersMixin.get_phone_numbers, and makeasked_bya required argument offormset_for, so the limit is never charged toNone.Related to #142 (closed), which introduced the limit; this is an incomplete fix.
tests/test_phone_verification.py:250-268checks only that the wording changes.Done when
tests/test_phone_verification.pyspends the allowance and then submits a formset with one taken and one free number. It expects both rows to be refused with the same sentence. Today the free row is accepted.