PST-016: Username existence is confirmed by the sign-up form and by Settings, Account #745

Closed
opened 2026-10-10 10:55:54 +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-016. 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: enumeration
  • Location: src/postulo/accounts/forms.py (1600-1605)
  • Found by: information disclosure, ownership review of accounts

Who can do it, and what they need. Unauthenticated stranger while registration is open; any account holder otherwise. No other precondition.

How it works.

POST /accounts/signup/ with username=<candidate> (registration open) or POST /settings/account/ with username=<candidate> while signed in; the response says "A user with that username already exists." exactly when the finding is somebody's sign-in name. Verified by scratch test for both forms (HITS: ['A user with that username already exists.']). Combined with the pre-claim issue this turns a username list into an email list.

Impact. Enumeration of sign-in identifiers across the instance by any member (and by strangers when registration is open). Usernames are low-sensitivity on their own but are half of what the lockout oracle needs, and the threat model says every surface "never confirms whether a record exists".

Evidence.

src/postulo/accounts/forms.py:1600-1605: `def clean_username(self): username = self.cleaned_data["username"].strip().casefold(); if username == self.instance.username: return username; return get_adapter().clean_username(username)` (allauth's clean_username raises `username_taken`). Scratch output for both forms: `A user with that username already exists.`

Verification (verdict: confirmed; severity after verification: Low). Keep low (arguably informational for a default, invite-only instance). Preconditions: for the stranger path, an operator has opened registration (or the attacker holds an invitation link), and the signup rate limit is 20 probes per minute per IP. For the member path, any signed-in account, with no throttle on POST /settings/account/. Impact is limited to confirming that a lowercase username exists on the instance. Email existence is not leaked at signup because ACCOUNT_PREVENT_ENUMERATION is on. The sentence 'the threat model says every surface never confirms whether a record exists' should be dropped, since that line is about the API. The email-list claim should read: confirms a guessed email against a known username through the shared per-account lockout key, one account at a time, at the cost of locking the victim out for five minutes. Remediation to suggest: throttle the settings username check (as phone_numbers.collision_message does with throttle.consume), and accept or document the signup disclosure as inherent to username-based sign-up.

Suggested remediation. Accept this as inherent to usernames and document it, or reduce the surface: rate-limit username changes per account (allauth has no limit on Settings → Account), and consider removing the username from sign-in/signup (ACCOUNT_LOGIN_METHODS = {"email"}) so a username is a display handle rather than a credential that is worth enumerating.

Also covered by this issue.

  • Username existence is disclosed by the Account and signup forms (src/postulo/accounts/forms.py (1600-1605); Informational). Reveals which usernames exist, contradicting the signup help text 'Nobody else sees it'. Usernames are not secrets and are shown to administrators, so impact is minimal; this is the standard behaviour of any unique-username system.
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-016**. 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:** enumeration - **Location:** `src/postulo/accounts/forms.py` (1600-1605) - **Found by:** information disclosure, ownership review of accounts **Who can do it, and what they need.** Unauthenticated stranger while registration is open; any account holder otherwise. No other precondition. **How it works.** POST `/accounts/signup/` with `username=<candidate>` (registration open) or POST `/settings/account/` with `username=<candidate>` while signed in; the response says "A user with that username already exists." exactly when the finding is somebody's sign-in name. Verified by scratch test for both forms (`HITS: ['A user with that username already exists.']`). Combined with the pre-claim issue this turns a username list into an email list. **Impact.** Enumeration of sign-in identifiers across the instance by any member (and by strangers when registration is open). Usernames are low-sensitivity on their own but are half of what the lockout oracle needs, and the threat model says every surface "never confirms whether a record exists". **Evidence.** ```text src/postulo/accounts/forms.py:1600-1605: `def clean_username(self): username = self.cleaned_data["username"].strip().casefold(); if username == self.instance.username: return username; return get_adapter().clean_username(username)` (allauth's clean_username raises `username_taken`). Scratch output for both forms: `A user with that username already exists.` ``` **Verification** (verdict: confirmed; severity after verification: Low). Keep low (arguably informational for a default, invite-only instance). Preconditions: for the stranger path, an operator has opened registration (or the attacker holds an invitation link), and the signup rate limit is 20 probes per minute per IP. For the member path, any signed-in account, with no throttle on POST /settings/account/. Impact is limited to confirming that a lowercase username exists on the instance. Email existence is not leaked at signup because ACCOUNT_PREVENT_ENUMERATION is on. The sentence 'the threat model says every surface never confirms whether a record exists' should be dropped, since that line is about the API. The email-list claim should read: confirms a guessed email against a known username through the shared per-account lockout key, one account at a time, at the cost of locking the victim out for five minutes. Remediation to suggest: throttle the settings username check (as phone_numbers.collision_message does with throttle.consume), and accept or document the signup disclosure as inherent to username-based sign-up. **Suggested remediation.** Accept this as inherent to usernames and document it, or reduce the surface: rate-limit username changes per account (allauth has no limit on Settings → Account), and consider removing the username from sign-in/signup (`ACCOUNT_LOGIN_METHODS = {"email"}`) so a username is a display handle rather than a credential that is worth enumerating. **Also covered by this issue.** - **Username existence is disclosed by the Account and signup forms** (`src/postulo/accounts/forms.py` (1600-1605); Informational). Reveals which usernames exist, contradicting the signup help text 'Nobody else sees it'. Usernames are not secrets and are shown to administrators, so impact is minimal; this is the standard behaviour of any unique-username system.
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:27 +00:00
Author
Owner

Fixed in d4c87ab54 on main.

Fixed in d4c87ab54 on main.
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#745
No description provided.