PST-007: Single sign-on auto-signup redeems an invitation that is bound to a different email address #736

Closed
opened 2026-10-10 10:55:46 +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-007. 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: authorization / registration policy bypass
  • Location: src/postulo/accounts/social_adapter.py (49-60)
  • Found by: ownership review of accounts
  • Related: PST-001 (#730), PST-003 (#732)

Who can do it, and what they need. Anyone who obtains an invitation link that an administrator addressed to somebody else (forwarded chat message, shared screenshot, shoulder-surfed URL) and who can authenticate at the instance's configured OpenID Connect provider. …

How it works.

  1. Administrator issues an invitation bound to wanted@example.org and hands over the link.
  2. Attacker opens /accounts/invitation/<token>/ once; InviteAcceptView stores the fingerprint in the session and redirects to the signup page.
  3. Instead of filling the form, the attacker clicks the SSO button and signs in at the provider as gatecrasher@example.org.
  4. allauth's process_signup calls SocialAccountAdapter.is_open_for_signup, which returns True because pending_invite(request) is not None, without looking at the invite's email.
  5. Because SOCIALACCOUNT_AUTO_SIGNUP is True, allauth's auto-signup path (socialaccount/internal/flows/signup.py process_signup -> save_user -> complete_social_signup) never instantiates a signup form, so AccountAdapter.clean_email, the only place the invite-address binding is enforced, is never called.
  6. user_signed_up fires; consume_invite calls invite.accept(user), which spends the invitation for the attacker regardless of address (Invite.accept checks only accepted_at/expires_at). An account exists for gatecrasher@ on a closed instance, and the invitation the intended person holds is now spent. Confirmed with a scratch test at [scratch test] output 'CREATED gatecrasher INVITE_ACCEPTED True BY gatecrasher'.

Impact. The documented promise in adapter.py ('without this check, an invitation addressed to one person could be redeemed by anyone holding the link') does not hold on the SSO path. A closed instance gains an account for somebody the administrator did not invite, and the intended recipient's invitation is burned. The account holds only its own data afterwards, so this is a registration-policy bypass rather than cross-account data access.

Evidence.

social_adapter.py:49-60:
    def is_open_for_signup(self, request: HttpRequest, sociallogin) -> bool:
        if getattr(settings, "POSTULO_OIDC_AUTO_SIGNUP", False):
            return True
        if site.signup_open_now():
            return True
        return pending_invite(request) is not None

adapter.py:119-134 (only reached from a signup *form*):
    def clean_email(self, email: str) -> str:
        ...
        invite = pending_invite(request)
        if invite and invite.email and invite.email.casefold() != email.casefold():
            raise ValidationError(...)

signals.py:81-83:
    invite = pending_invite(request)
    if invite is not None:
        if invite.accept(user):

mode
…

Verification (verdict: confirmed; severity after verification: Low; first-pass claim: Medium). The cited files and lines are right (social_adapter.py is_open_for_signup, last line 60; signals.py:67-98; adapter.py:119-134). One correction to the impact: the SSO-created account is left with an unverified email row (verified=False) and the login stops at /accounts/confirm-email/ with no session, because consume_invite only marks the address verified when it matches the invitation's. The attacker therefore still has to confirm the mailbox, which they hold anyway as a provider identity. The account row exists and the invitation is spent regardless. Remediation: in SocialAccountAdapter.is_open_for_signup, or in a pre-signup hook, compare the invitation's address (when it has one) with the provider's address and refuse on a mismatch, and add a test for the mismatched case beside the existing matching-address test.

Suggested remediation. Make the binding a property of the invitation rather than of one form: in SocialAccountAdapter.is_open_for_signup, when the door is being opened by a pending invitation that names an address, compare it (casefold) with the provider's verified address and return False on mismatch; and/or make Invite.accept(user) (or consume_invite) refuse to spend an invitation whose email does not match user.email, raising PermissionDenied so the transaction rolls the account back, the way the race case already does. Add a test beside test_sso.py that follows a bound invitation and arrives through the provider with another address.

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-007**. 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:** authorization / registration policy bypass - **Location:** `src/postulo/accounts/social_adapter.py` (49-60) - **Found by:** ownership review of accounts - **Related:** PST-001 (#730), PST-003 (#732) **Who can do it, and what they need.** Anyone who obtains an invitation link that an administrator addressed to somebody else (forwarded chat message, shared screenshot, shoulder-surfed URL) and who can authenticate at the instance's configured OpenID Connect provider. … **How it works.** 1. Administrator issues an invitation bound to wanted@example.org and hands over the link. 2. Attacker opens /accounts/invitation/&lt;token>/ once; InviteAcceptView stores the fingerprint in the session and redirects to the signup page. 3. Instead of filling the form, the attacker clicks the SSO button and signs in at the provider as gatecrasher@example.org. 4. allauth's process_signup calls SocialAccountAdapter.is_open_for_signup, which returns True because pending_invite(request) is not None, without looking at the invite's email. 5. Because SOCIALACCOUNT_AUTO_SIGNUP is True, allauth's auto-signup path (socialaccount/internal/flows/signup.py process_signup -> save_user -> complete_social_signup) never instantiates a signup form, so AccountAdapter.clean_email, the only place the invite-address binding is enforced, is never called. 6. user_signed_up fires; consume_invite calls invite.accept(user), which spends the invitation for the attacker regardless of address (Invite.accept checks only accepted_at/expires_at). An account exists for gatecrasher@ on a closed instance, and the invitation the intended person holds is now spent. Confirmed with a scratch test at [scratch test] output 'CREATED gatecrasher INVITE_ACCEPTED True BY gatecrasher'. **Impact.** The documented promise in adapter.py ('without this check, an invitation addressed to one person could be redeemed by anyone holding the link') does not hold on the SSO path. A closed instance gains an account for somebody the administrator did not invite, and the intended recipient's invitation is burned. The account holds only its own data afterwards, so this is a registration-policy bypass rather than cross-account data access. **Evidence.** ```text social_adapter.py:49-60: def is_open_for_signup(self, request: HttpRequest, sociallogin) -> bool: if getattr(settings, "POSTULO_OIDC_AUTO_SIGNUP", False): return True if site.signup_open_now(): return True return pending_invite(request) is not None adapter.py:119-134 (only reached from a signup *form*): def clean_email(self, email: str) -> str: ... invite = pending_invite(request) if invite and invite.email and invite.email.casefold() != email.casefold(): raise ValidationError(...) signals.py:81-83: invite = pending_invite(request) if invite is not None: if invite.accept(user): mode … ``` **Verification** (verdict: confirmed; severity after verification: Low; first-pass claim: Medium). The cited files and lines are right (social_adapter.py is_open_for_signup, last line 60; signals.py:67-98; adapter.py:119-134). One correction to the impact: the SSO-created account is left with an unverified email row (verified=False) and the login stops at /accounts/confirm-email/ with no session, because consume_invite only marks the address verified when it matches the invitation's. The attacker therefore still has to confirm the mailbox, which they hold anyway as a provider identity. The account row exists and the invitation is spent regardless. Remediation: in SocialAccountAdapter.is_open_for_signup, or in a pre-signup hook, compare the invitation's address (when it has one) with the provider's address and refuse on a mismatch, and add a test for the mismatched case beside the existing matching-address test. **Suggested remediation.** Make the binding a property of the invitation rather than of one form: in SocialAccountAdapter.is_open_for_signup, when the door is being opened by a pending invitation that names an address, compare it (casefold) with the provider's verified address and return False on mismatch; and/or make Invite.accept(user) (or consume_invite) refuse to spend an invitation whose email does not match user.email, raising PermissionDenied so the transaction rolls the account back, the way the race case already does. Add a test beside test_sso.py that follows a bound invitation and arrives through the provider with another address.
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:21 +00:00
Author
Owner

Fixed in a62c2b011 on main.

Fixed in a62c2b011 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#736
No description provided.