PST-003: An invitation bound to an address marks that address verified at sign-up although Postulo never delivered the invitation #732

Closed
opened 2026-10-10 10:55:43 +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-003. 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: identity-verification
  • Location: src/postulo/accounts/signals.py (67-90)
  • Found by: authentication and sessions
  • Related: PST-001 (#730), PST-002 (#731), PST-007 (#736)

Who can do it, and what they need. Whoever the administrator hands the link to, or anyone who finds it on the channel it was handed over on. Precondition: an invitation with an email filled in.

How it works.

  1. Administrator creates an invitation for alice@example.org; the link is rendered once to the administrator and sent nowhere by Postulo.
  2. The administrator pastes it into a chat, or hands it to the wrong person; the holder follows it and signs up with alice@example.org as the adapter demands.
  3. consume_invite marks alice@example.org verified with no mail sent (scratch test: 'verified without any mail: True mails sent: 0').
  4. The new account now holds a verified address its holder does not control: password-reset mails for that account go to the real Alice, and the real Alice can never register that address (ACCOUNT_UNIQUE_EMAIL), nor can the mismatch be noticed from the People page.

Impact. Mis-delivery of an invitation produces an account with a 'proven' address that was never proven, contradicting the threat model's 'addresses verified before use' and blocking the real holder; limited to administrator error or a leaked link.

Evidence.

signals.py:71-73 docstring: 'An invitation addressed to one email address was delivered to that address, and following its link is proof of holding the mailbox' — but views.py:408-418 only renders `token_url` into accounts/invite_created.html for the administrator; grep shows no send of an invitation mail. signals.py:83-87 `if invite.accept(user): if invite.email and invite.email.casefold() == (user.email or "").casefold(): EmailAddress.objects.filter(user=user, email__iexact=user.email).update(verified=True)`.

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

The code does what the finding says. consume_invite (signals.py:83-87) marks the typed address verified when it equals the invitation's address, and Postulo never delivers the invitation. InviteCreateView only renders the link to the administrator, and invite_created.html says 'Send the link however you like'. The docstring's premise that the invitation 'was delivered to that address' is therefore not enforced. With ACCOUNT_EMAIL_VERIFICATION='mandatory', verification would otherwise be the only thing stopping a link holder who is not the invitee. The project's own intent (adapter.clean_email docstring and the tests/test_invites.py header: an invitation addressed to one person should not be redeemable by whoever else ends up holding the link) is defeated, because the holder simply types the bound address and is signed in at once. The finding understates the impact. allauth's defence against pre-account-hijacking is wipe_password(), which clears the password of a local account whose address is NOT verified when an SSO login with the same provider-verified address lands on it. A falsely verified address skips that defence. With OIDC configured and POSTULO_OIDC_LINK_BY_EMAIL on (the default), the real invitee who later signs in via SSO is connected to the holder's account, and the holder keeps a working password. Without OIDC the effect is address squatting, and the real person can still use password reset to take the account over, so the finding's 'can never register' is overstated. The unproven address and the neutralised wipe_password were demonstrated by scratch test. The SSO login step was exercised at the allauth-function level (authenticate_by_email plus wipe_password), not through a full OIDC round-trip.

Corrections from verification. Attacker: anyone who obtains an email-bound invitation link before the invitee uses it (wrong recipient, group chat, chat log). Preconditions: a bound invitation handed over by a channel other than mail to that mailbox. For the account-takeover amplification, additionally OIDC configured with link-by-email on (the default once OIDC is set up), and the invitee later signing in via SSO. Effect: the holder gets a signed-in account carrying a falsely verified address and bypasses the binding. Where SSO linking applies, the invitee is later signed into the holder's account (allauth's pre-hijack wipe skipped), so the holder shares access to whatever the invitee stores there. Without OIDC it is address squatting only. Correct step 4: password reset mails go to the real person, who can take the account over that way. Remediation: do not mark the address verified from the invite alone. Either mail the invitation to the bound address (making the docstring true) or let allauth send the normal verification mail for bound invitations, and keep the verified shortcut only if Postulo delivers the link itself. Lines signals.py:67-90 and views.py:395-420 are correct, and the binding is enforced in adapter.py:119-135.

Verification evidence.

Scratch tests test_42_bound_invite_verifies_address_without_any_mail and test_42_amplification_sso_pre_hijack_defence_is_defeated passed.
verified without any mail: True mails sent: 0 signed in: True redirect: /
control unbound: verified = False mails: 1
amplification: SSO login for alice resolves to: newcomer (link_by_email default: True )
amplification: attacker's password still works after allauth's wipe_password: True
control (unverified): password usable after wipe: False
The invitation was created through the real staff view and mail.outbox was empty. The holder followed /accounts/invitation/<token>/ and signed up as alice@example.org with their own password, and was logged in immediately.
Code: signals.py:83-87 `if invite.accept(user): if invite.email and invite.email.casefold() == (user.email or "").casefold(): EmailAddress.objects.filter(user=user, email__iexact=user.email).update(verified=True)`. allauth/socialaccount/internal/flows/email_authentication.py: wipe_password() returns early `if address and address.verified: return`. Config: base.py:243 ACCOUNT_EMAIL_VERIFICATION='mandatory', base.py:351-352 SOCIALACCOUNT_EMAIL_AUTHENTICATION(_AUTO_CONNECT)=POSTULO_OIDC_LINK_BY_EMAIL (default True). Static reading of allauth login.py shows _login() calls _accept_login(), which calls wipe_password() then connect(). invite_created.html: 'For {{ email }}, and usable only by that address. Send the link however you like.'

Suggested remediation. Either send the invitation to the bound address from Postulo (then the link is proof) or leave the address unverified and let allauth's mandatory verification run as for any other sign-up; say on the invite page which of the two applies.

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-003**. 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:** identity-verification - **Location:** `src/postulo/accounts/signals.py` (67-90) - **Found by:** authentication and sessions - **Related:** PST-001 (#730), PST-002 (#731), PST-007 (#736) **Who can do it, and what they need.** Whoever the administrator hands the link to, or anyone who finds it on the channel it was handed over on. Precondition: an invitation with an email filled in. **How it works.** 1. Administrator creates an invitation for alice@example.org; the link is rendered once to the administrator and sent nowhere by Postulo. 2. The administrator pastes it into a chat, or hands it to the wrong person; the holder follows it and signs up with alice@example.org as the adapter demands. 3. consume_invite marks alice@example.org verified with no mail sent (scratch test: 'verified without any mail: True mails sent: 0'). 4. The new account now holds a verified address its holder does not control: password-reset mails for that account go to the real Alice, and the real Alice can never register that address (ACCOUNT_UNIQUE_EMAIL), nor can the mismatch be noticed from the People page. **Impact.** Mis-delivery of an invitation produces an account with a 'proven' address that was never proven, contradicting the threat model's 'addresses verified before use' and blocking the real holder; limited to administrator error or a leaked link. **Evidence.** ```text signals.py:71-73 docstring: 'An invitation addressed to one email address was delivered to that address, and following its link is proof of holding the mailbox' — but views.py:408-418 only renders `token_url` into accounts/invite_created.html for the administrator; grep shows no send of an invitation mail. signals.py:83-87 `if invite.accept(user): if invite.email and invite.email.casefold() == (user.email or "").casefold(): EmailAddress.objects.filter(user=user, email__iexact=user.email).update(verified=True)`. ``` **Verification** (verdict: confirmed; severity after verification: Medium; first-pass claim: Low). The code does what the finding says. consume_invite (signals.py:83-87) marks the typed address verified when it equals the invitation's address, and Postulo never delivers the invitation. InviteCreateView only renders the link to the administrator, and invite_created.html says 'Send the link however you like'. The docstring's premise that the invitation 'was delivered to that address' is therefore not enforced. With ACCOUNT_EMAIL_VERIFICATION='mandatory', verification would otherwise be the only thing stopping a link holder who is not the invitee. The project's own intent (adapter.clean_email docstring and the tests/test_invites.py header: an invitation addressed to one person should not be redeemable by whoever else ends up holding the link) is defeated, because the holder simply types the bound address and is signed in at once. The finding understates the impact. allauth's defence against pre-account-hijacking is wipe_password(), which clears the password of a local account whose address is NOT verified when an SSO login with the same provider-verified address lands on it. A falsely verified address skips that defence. With OIDC configured and POSTULO_OIDC_LINK_BY_EMAIL on (the default), the real invitee who later signs in via SSO is connected to the holder's account, and the holder keeps a working password. Without OIDC the effect is address squatting, and the real person can still use password reset to take the account over, so the finding's 'can never register' is overstated. The unproven address and the neutralised wipe_password were demonstrated by scratch test. The SSO login step was exercised at the allauth-function level (authenticate_by_email plus wipe_password), not through a full OIDC round-trip. **Corrections from verification.** Attacker: anyone who obtains an email-bound invitation link before the invitee uses it (wrong recipient, group chat, chat log). Preconditions: a bound invitation handed over by a channel other than mail to that mailbox. For the account-takeover amplification, additionally OIDC configured with link-by-email on (the default once OIDC is set up), and the invitee later signing in via SSO. Effect: the holder gets a signed-in account carrying a falsely verified address and bypasses the binding. Where SSO linking applies, the invitee is later signed into the holder's account (allauth's pre-hijack wipe skipped), so the holder shares access to whatever the invitee stores there. Without OIDC it is address squatting only. Correct step 4: password reset mails go to the real person, who can take the account over that way. Remediation: do not mark the address verified from the invite alone. Either mail the invitation to the bound address (making the docstring true) or let allauth send the normal verification mail for bound invitations, and keep the verified shortcut only if Postulo delivers the link itself. Lines signals.py:67-90 and views.py:395-420 are correct, and the binding is enforced in adapter.py:119-135. **Verification evidence.** ```text Scratch tests test_42_bound_invite_verifies_address_without_any_mail and test_42_amplification_sso_pre_hijack_defence_is_defeated passed. verified without any mail: True mails sent: 0 signed in: True redirect: / control unbound: verified = False mails: 1 amplification: SSO login for alice resolves to: newcomer (link_by_email default: True ) amplification: attacker's password still works after allauth's wipe_password: True control (unverified): password usable after wipe: False The invitation was created through the real staff view and mail.outbox was empty. The holder followed /accounts/invitation/<token>/ and signed up as alice@example.org with their own password, and was logged in immediately. Code: signals.py:83-87 `if invite.accept(user): if invite.email and invite.email.casefold() == (user.email or "").casefold(): EmailAddress.objects.filter(user=user, email__iexact=user.email).update(verified=True)`. allauth/socialaccount/internal/flows/email_authentication.py: wipe_password() returns early `if address and address.verified: return`. Config: base.py:243 ACCOUNT_EMAIL_VERIFICATION='mandatory', base.py:351-352 SOCIALACCOUNT_EMAIL_AUTHENTICATION(_AUTO_CONNECT)=POSTULO_OIDC_LINK_BY_EMAIL (default True). Static reading of allauth login.py shows _login() calls _accept_login(), which calls wipe_password() then connect(). invite_created.html: 'For {{ email }}, and usable only by that address. Send the link however you like.' ``` **Suggested remediation.** Either send the invitation to the bound address from Postulo (then the link is proof) or leave the address unverified and let allauth's mandatory verification run as for any other sign-up; say on the invite page which of the two applies.
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#732
No description provided.