PST-007: Single sign-on auto-signup redeems an invitation that is bound to a different email address #736
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#736
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 in the security audit of 2026-10-10 (
SECURITY-AUDIT-2026-10-10.md), run againstmainatc30851bfrom 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.
src/postulo/accounts/social_adapter.py(49-60)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.
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.
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.
Fixed in
a62c2b011on main.