PST-002: A member can pre-claim an unused email address; a password reset or sign-in code then puts its real holder inside the claimant's account #731

Closed
opened 2026-10-10 10:55:42 +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-002. 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: account-takeover / pre-account hijack
  • Location: src/postulo/config/settings/base.py (243-246)
  • Found by: authentication and sessions
  • Related: PST-001 (#730), PST-003 (#732)

Who can do it, and what they need. Any signed-in member of the instance (an invited colleague, or a stranger where registration is open). Precondition: the target address has no local account yet (a future colleague, somebody the administrator is about to invite). For the sign-in-code leg the administrator must have switched on 'Sign in with a code sent by email'; the reset leg works on every instance that can send mail.

How it works.

  1. Attacker (member 'mallory', verified primary mallory@example.org) opens /accounts/email/ and adds victim@example.org. allauth's AddEmailForm/assess_unique_email only refuses an address another account already holds, so an unheld address is stored as an unverified EmailAddress row on mallory's account; nothing ever expires it.
  2. Denial: when the victim later signs up with victim@example.org (open registration or an invitation), allauth's assess_unique_email returns None (conflict hidden behind mandatory verification + enumeration prevention): the form 'succeeds' with a redirect to /accounts/confirm-email/ but no account is created and, in the test harness, no mail was sent either; the victim can never register that address.
  3. Capture by reset: the victim (or anyone) posts victim@example.org to /accounts/password/reset/. ResetPasswordForm.clean_email calls filter_users_by_email(prefer_verified=True), which falls back to the unverified row and selects mallory's account; a reset key for mallory's account is mailed to victim@example.org together with 'your username is mallory'. The victim sets a password and signs in by that username (no verification stage, because mallory's primary is verified): the victim is now working inside the attacker's account, and everything they enter (CV, addresses, applications) is readable by mallory, who regains access any time with a reset to her own verified primary.
  4. Capture by code: with email sign-in enabled, posting victim@example.org to /accounts/login/code/ mails a code for mallory's account to the victim; typing it signs the victim straight into mallory's account. All three legs were reproduced with scratch tests ([scratch test]): 'accounts: [mallory]' after the victim's signup, 'login by username landed / as mallory', 'confirm status 200 as mallory'.

Impact. Cross-account: a member chooses a person who does not yet have an account and arranges that this person's documents and personal data are entered into the member's own account, or at minimum that the person can never register their address. Breaks the threat-model promise that 'usernames and addresses are verified before use' for the two credential flows that rely on an address.

Evidence.

base.py:243 `ACCOUNT_EMAIL_VERIFICATION = "mandatory"`; :245 `ACCOUNT_UNIQUE_EMAIL = True`; :246 `ACCOUNT_MAX_EMAIL_ADDRESSES = 5`. allauth/account/forms.py:626-628 (ResetPasswordForm.clean_email) `self.users = filter_users_by_email(email, is_active=True, prefer_verified=True, for_login=True)`; allauth/account/forms.py:759-761 (RequestLoginCodeForm.clean_email) same call; allauth/account/internal/userkit.py:166-173 `if prefer_verified: verified_mails = ...; if verified_mails: mails = verified_mails` — otherwise the unverified rows are used. allauth/account/internal/flows/manage_email.py:164-171 returns None (hide the conflict, create nothing) when the clash is with an unverified row under mandatory verification. accounts/addresses.py:19-35 `is_offered` only decides whether the link is shown; allauth's /accounts/email/ view stays reachable. No Postulo code narrows either lookup to verified rows.

Verification (verdict: confirmed; severity after verification: Medium; first-pass claim: High).

The mechanism is real and nothing in Postulo stops it. A signed-in member can store an address nobody holds as an unverified EmailAddress row on their own account through allauth's /accounts/email/ route, and the route stays reachable when the 'Several email addresses' feature is switched off, because addresses.is_offered() only hides the link. allauth then treats that unverified row as a recovery route in two places. ResetPasswordForm.clean_email and RequestLoginCodeForm.clean_email call filter_users_by_email(prefer_verified=True), which falls back to unverified rows when no verified row exists. The address's real holder therefore receives a reset key, or a sign-in code, for the attacker's account. I reproduced all three legs. (1) The victim cannot register: signup redirects to /accounts/confirm-email/ and creates no account. (2) A password reset mailed to the victim lets them set the password on mallory's account, and they then sign in as mallory by the username named in the reset mail. (3) With email sign-in switched on, the code mailed to the victim signs them into mallory's account. I looked for a mitigation and found none. ACCOUNT_FORMS overrides no reset form; AccountAdapter only overrides clean_email for invitations; no Postulo code narrows either lookup to verified rows; no tests/security test pins the safe behaviour (tests/security/test_email_sign_in.py only covers verified addresses). The only attacker is an ordinary member, who is listed in the threat model as an attacker rather than a trusted party. base.py:241 states 'Every address is proven by a link before it is used: to sign in, and to be primary', and reset and code delivery are uses that this does not cover.

I lowered the severity from high to medium for these reasons:

  • The attacker needs an account and must know a future user's address. On the invite-only default that means an invited member targeting an invitee.
  • The attacker reads no existing data. The victim has no account yet, and the attacker only gains access to what the victim types after landing in the pre-seeded account.
  • The victim must take part by requesting the reset or the code. The signup dead end does push them there, because allauth mails an 'Account Already Exists' message that points to the password-forgotten procedure.
  • The code leg also needs an administrator to have enabled email sign-in, which is off by default.
  • The victim is not silent about it. The account carries the attacker's primary address and username, though the attacker can pick a plausible username and display name.
  • The weakness is a real pre-hijack: the victim cannot register, and the attacker keeps a way back in through their own verified primary address.

Corrections from verification. File and lines: the setting is at src/postulo/config/settings/base.py:241-246, and the comment at :241 is the claim that does not hold. The real defect is an absence: no override restricts reset or code delivery to verified addresses, and the add-address route is not gated.

Corrections to the auditor's path:
(a) Signup shows no mail in the harness only because the attacker's add consumed the per-address verification-mail rate limit. Once the window passes, the victim does receive an 'Account Already Exists' mail pointing to the password reset, which funnels them into leg 3.
(b) After the reset the victim's address is still unverified on mallory's account. Signing in by that address is stopped at /accounts/confirm-email/, but signing in by the username named in the reset mail succeeds as mallory.
(c) The confirmation link the attacker's add mails to the victim does not log an anonymous browser in. It only marks the address verified on mallory's account.
(d) The code leg needs the administrator-controlled email sign-in switch, which is off by default. The reset and signup legs work on any instance that can send mail.
(e) The attacker gains no pre-existing data of anyone. They gain access to whatever the victim enters into the pre-seeded account, and a standing way back in through their own verified primary address.

Suggested remediation: restrict the reset and login-code lookups to verified addresses, with a custom reset form via ACCOUNT_FORMS and a login-code form or view. Alternatively, do not store a new address on an account until the mailbox owner confirms it, or let a signup or verification of that address by its real holder displace an unverified claim.

Verification evidence.

Scratch module [scratch test], run with the specified pytest command. Output pasted:

PRECLAIM rows: [{'user__username': 'mallory', 'verified': False, 'primary': False}]
PRECLAIM mails: [(['victim@example.org'], '[testserver] Please Confirm Your Email Address')]

Feature policy forced OFF (addresses.is_offered(mallory) is False): 'feature off -> add status 302 rows [{'user__username': 'mallory', 'verified': False}]'

Signup leg: 'SIGNUP status 302 /accounts/confirm-email/ form errors: None'; 'SIGNUP accounts: [mallory]'; 'SIGNUP mails: []'. The empty mailbox is allauth's per-address verification-mail rate limit, consumed by the attacker's add. After cache.clear(): 'SIGNUP2 mails: [(victim@example.org, Account Already Exists)]', body 'an account using that email address already exists... please use the password forgotten procedure to recover your account: http://testserver/accounts/password/reset/'.

Reset leg: 'RESET status 302 mails [(victim@example.org, Password Reset Email)]'. The body contains 'http://testserver/accounts/password/reset/key/1-dg87h3-.../' and 'In case you forgot, your username is mallory.' The victim follows the link and sets a password: 'RESET set-password status 302 /accounts/password/reset/key/done/'. Then preclaimed.check_password(new) is True. Afterwards: 'RESET addresses after: mallory@example.org verified primary; victim@example.org verified False'. 'LOGIN BY ADDRESS landed /accounts/confirm-email/ user AnonymousUser' (blocked pending verification). 'LOGIN BY USERNAME landed / user mallory' (assert r.context['user'] == preclaimed passes).

Confirmation mail clicked by an anonymous browser: 'CONFIRM POST 200 /accounts/login/ user AnonymousUser; CONFIRM row verified True owner mallory; session logged in: False'. It verifies the address on mallory's account but does not sign the victim in, so that variant is not an additional leg.

Code leg (SiteSettings.email_sign_in=True): 'CODE request status 302 /accounts/login/code/confirm/ mails [(victim@example.org, Sign-In Code)]', code XZWC-DNCV in the body to the victim. 'CODE confirm 200 / as mallory'.

Code read: allauth forms.py ResetPasswordForm.clean_email uses `filter_users_by_email(email, is_active=True, prefer_verified=True, for_login=True)`. userkit.py: `if prefer_verified: verified_mails = ...; if verified_mails: mails = verified_mails` (fallback to unverified rows otherwise). manage_email.assess_unique_email returns None under mandatory verification and enumeration prevention, so signup makes no account. src/postulo/accounts/adapter.py AccountAdapter overrides only get_login_stages, is_open_for_signup and clean_email (invitation only). base.py: `ACCOUNT_FORMS` has no reset_password or request_login_code entry.

Suggested remediation. Treat an unverified address as not a credential: override allauth's ResetPasswordForm / RequestLoginCodeForm (or the adapter's user lookup) so that a reset key or a sign-in code is only ever generated for an account on which that address is verified, answering the unknown-address path otherwise. Stop an account from holding somebody else's address indefinitely: expire unverified EmailAddress rows after the confirmation key's own lifetime (a scheduler errand), and/or let a sign-up proceed when the only conflicting rows are unverified (delete them on the newcomer's verification, which allauth already does for duplicates). Consider requiring reauthentication to add an address and enforcing the email-addresses feature switch on the view, not only on the link. Add a tests/security case that pre-claims an address and asserts the holder can still register and that no reset or code is sent to it for another account.

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-002**. 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:** account-takeover / pre-account hijack - **Location:** `src/postulo/config/settings/base.py` (243-246) - **Found by:** authentication and sessions - **Related:** PST-001 (#730), PST-003 (#732) **Who can do it, and what they need.** Any signed-in member of the instance (an invited colleague, or a stranger where registration is open). Precondition: the target address has no local account yet (a future colleague, somebody the administrator is about to invite). For the sign-in-code leg the administrator must have switched on 'Sign in with a code sent by email'; the reset leg works on every instance that can send mail. **How it works.** 1. Attacker (member 'mallory', verified primary mallory@example.org) opens /accounts/email/ and adds victim@example.org. allauth's AddEmailForm/assess_unique_email only refuses an address another account already holds, so an unheld address is stored as an unverified EmailAddress row on mallory's account; nothing ever expires it. 2. Denial: when the victim later signs up with victim@example.org (open registration or an invitation), allauth's assess_unique_email returns None (conflict hidden behind mandatory verification + enumeration prevention): the form 'succeeds' with a redirect to /accounts/confirm-email/ but no account is created and, in the test harness, no mail was sent either; the victim can never register that address. 3. Capture by reset: the victim (or anyone) posts victim@example.org to /accounts/password/reset/. ResetPasswordForm.clean_email calls filter_users_by_email(prefer_verified=True), which falls back to the unverified row and selects mallory's account; a reset key for mallory's account is mailed to victim@example.org together with 'your username is mallory'. The victim sets a password and signs in by that username (no verification stage, because mallory's primary is verified): the victim is now working inside the attacker's account, and everything they enter (CV, addresses, applications) is readable by mallory, who regains access any time with a reset to her own verified primary. 4. Capture by code: with email sign-in enabled, posting victim@example.org to /accounts/login/code/ mails a code for mallory's account to the victim; typing it signs the victim straight into mallory's account. All three legs were reproduced with scratch tests ([scratch test]): 'accounts: [mallory]' after the victim's signup, 'login by username landed / as mallory', 'confirm status 200 as mallory'. **Impact.** Cross-account: a member chooses a person who does not yet have an account and arranges that this person's documents and personal data are entered into the member's own account, or at minimum that the person can never register their address. Breaks the threat-model promise that 'usernames and addresses are verified before use' for the two credential flows that rely on an address. **Evidence.** ```text base.py:243 `ACCOUNT_EMAIL_VERIFICATION = "mandatory"`; :245 `ACCOUNT_UNIQUE_EMAIL = True`; :246 `ACCOUNT_MAX_EMAIL_ADDRESSES = 5`. allauth/account/forms.py:626-628 (ResetPasswordForm.clean_email) `self.users = filter_users_by_email(email, is_active=True, prefer_verified=True, for_login=True)`; allauth/account/forms.py:759-761 (RequestLoginCodeForm.clean_email) same call; allauth/account/internal/userkit.py:166-173 `if prefer_verified: verified_mails = ...; if verified_mails: mails = verified_mails` — otherwise the unverified rows are used. allauth/account/internal/flows/manage_email.py:164-171 returns None (hide the conflict, create nothing) when the clash is with an unverified row under mandatory verification. accounts/addresses.py:19-35 `is_offered` only decides whether the link is shown; allauth's /accounts/email/ view stays reachable. No Postulo code narrows either lookup to verified rows. ``` **Verification** (verdict: confirmed; severity after verification: Medium; first-pass claim: High). The mechanism is real and nothing in Postulo stops it. A signed-in member can store an address nobody holds as an unverified EmailAddress row on their own account through allauth's /accounts/email/ route, and the route stays reachable when the 'Several email addresses' feature is switched off, because addresses.is_offered() only hides the link. allauth then treats that unverified row as a recovery route in two places. ResetPasswordForm.clean_email and RequestLoginCodeForm.clean_email call filter_users_by_email(prefer_verified=True), which falls back to unverified rows when no verified row exists. The address's real holder therefore receives a reset key, or a sign-in code, for the attacker's account. I reproduced all three legs. (1) The victim cannot register: signup redirects to /accounts/confirm-email/ and creates no account. (2) A password reset mailed to the victim lets them set the password on mallory's account, and they then sign in as mallory by the username named in the reset mail. (3) With email sign-in switched on, the code mailed to the victim signs them into mallory's account. I looked for a mitigation and found none. ACCOUNT_FORMS overrides no reset form; AccountAdapter only overrides clean_email for invitations; no Postulo code narrows either lookup to verified rows; no tests/security test pins the safe behaviour (tests/security/test_email_sign_in.py only covers verified addresses). The only attacker is an ordinary member, who is listed in the threat model as an attacker rather than a trusted party. base.py:241 states 'Every address is proven by a link before it is used: to sign in, and to be primary', and reset and code delivery are uses that this does not cover. I lowered the severity from high to medium for these reasons: - The attacker needs an account and must know a future user's address. On the invite-only default that means an invited member targeting an invitee. - The attacker reads no existing data. The victim has no account yet, and the attacker only gains access to what the victim types after landing in the pre-seeded account. - The victim must take part by requesting the reset or the code. The signup dead end does push them there, because allauth mails an 'Account Already Exists' message that points to the password-forgotten procedure. - The code leg also needs an administrator to have enabled email sign-in, which is off by default. - The victim is not silent about it. The account carries the attacker's primary address and username, though the attacker can pick a plausible username and display name. - The weakness is a real pre-hijack: the victim cannot register, and the attacker keeps a way back in through their own verified primary address. **Corrections from verification.** File and lines: the setting is at src/postulo/config/settings/base.py:241-246, and the comment at :241 is the claim that does not hold. The real defect is an absence: no override restricts reset or code delivery to verified addresses, and the add-address route is not gated. Corrections to the auditor's path: (a) Signup shows no mail in the harness only because the attacker's add consumed the per-address verification-mail rate limit. Once the window passes, the victim does receive an 'Account Already Exists' mail pointing to the password reset, which funnels them into leg 3. (b) After the reset the victim's address is still unverified on mallory's account. Signing in by that address is stopped at /accounts/confirm-email/, but signing in by the username named in the reset mail succeeds as mallory. (c) The confirmation link the attacker's add mails to the victim does not log an anonymous browser in. It only marks the address verified on mallory's account. (d) The code leg needs the administrator-controlled email sign-in switch, which is off by default. The reset and signup legs work on any instance that can send mail. (e) The attacker gains no pre-existing data of anyone. They gain access to whatever the victim enters into the pre-seeded account, and a standing way back in through their own verified primary address. Suggested remediation: restrict the reset and login-code lookups to verified addresses, with a custom reset form via ACCOUNT_FORMS and a login-code form or view. Alternatively, do not store a new address on an account until the mailbox owner confirms it, or let a signup or verification of that address by its real holder displace an unverified claim. **Verification evidence.** ```text Scratch module [scratch test], run with the specified pytest command. Output pasted: PRECLAIM rows: [{'user__username': 'mallory', 'verified': False, 'primary': False}] PRECLAIM mails: [(['victim@example.org'], '[testserver] Please Confirm Your Email Address')] Feature policy forced OFF (addresses.is_offered(mallory) is False): 'feature off -> add status 302 rows [{'user__username': 'mallory', 'verified': False}]' Signup leg: 'SIGNUP status 302 /accounts/confirm-email/ form errors: None'; 'SIGNUP accounts: [mallory]'; 'SIGNUP mails: []'. The empty mailbox is allauth's per-address verification-mail rate limit, consumed by the attacker's add. After cache.clear(): 'SIGNUP2 mails: [(victim@example.org, Account Already Exists)]', body 'an account using that email address already exists... please use the password forgotten procedure to recover your account: http://testserver/accounts/password/reset/'. Reset leg: 'RESET status 302 mails [(victim@example.org, Password Reset Email)]'. The body contains 'http://testserver/accounts/password/reset/key/1-dg87h3-.../' and 'In case you forgot, your username is mallory.' The victim follows the link and sets a password: 'RESET set-password status 302 /accounts/password/reset/key/done/'. Then preclaimed.check_password(new) is True. Afterwards: 'RESET addresses after: mallory@example.org verified primary; victim@example.org verified False'. 'LOGIN BY ADDRESS landed /accounts/confirm-email/ user AnonymousUser' (blocked pending verification). 'LOGIN BY USERNAME landed / user mallory' (assert r.context['user'] == preclaimed passes). Confirmation mail clicked by an anonymous browser: 'CONFIRM POST 200 /accounts/login/ user AnonymousUser; CONFIRM row verified True owner mallory; session logged in: False'. It verifies the address on mallory's account but does not sign the victim in, so that variant is not an additional leg. Code leg (SiteSettings.email_sign_in=True): 'CODE request status 302 /accounts/login/code/confirm/ mails [(victim@example.org, Sign-In Code)]', code XZWC-DNCV in the body to the victim. 'CODE confirm 200 / as mallory'. Code read: allauth forms.py ResetPasswordForm.clean_email uses `filter_users_by_email(email, is_active=True, prefer_verified=True, for_login=True)`. userkit.py: `if prefer_verified: verified_mails = ...; if verified_mails: mails = verified_mails` (fallback to unverified rows otherwise). manage_email.assess_unique_email returns None under mandatory verification and enumeration prevention, so signup makes no account. src/postulo/accounts/adapter.py AccountAdapter overrides only get_login_stages, is_open_for_signup and clean_email (invitation only). base.py: `ACCOUNT_FORMS` has no reset_password or request_login_code entry. ``` **Suggested remediation.** Treat an unverified address as not a credential: override allauth's ResetPasswordForm / RequestLoginCodeForm (or the adapter's user lookup) so that a reset key or a sign-in code is only ever generated for an account on which that address is verified, answering the unknown-address path otherwise. Stop an account from holding somebody else's address indefinitely: expire unverified EmailAddress rows after the confirmation key's own lifetime (a scheduler errand), and/or let a sign-up proceed when the only conflicting rows are unverified (delete them on the newcomer's verification, which allauth already does for duplicates). Consider requiring reauthentication to add an address and enforcing the email-addresses feature switch on the view, not only on the link. Add a tests/security case that pre-claims an address and asserts the holder can still register and that no reset or code is sent to it for another account.
tiagoagueda added this to the 0.5.0 milestone 2026-10-10 10:57:18 +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#731
No description provided.