PST-001: Single sign-on links a provider-verified address to a local account that holds it only unverified #730
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#730
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-001. Severity: High.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(62-78);src/postulo/config/settings/base.py(336-352)Who can do it, and what they need. Any signed-in member. Preconditions: an OpenID Connect provider is configured and POSTULO_OIDC_LINK_BY_EMAIL is left at its default (on); the victim has no local account yet and will arrive through the provider (auto-signup on, or an invitation, or simply linking). Works with POSTULO_OIDC_AUTO_SIGNUP at its default of off.
How it works.
Impact. Everything the SSO user enters from then on is in the attacker's account and readable by the attacker; the attacker can also later change the victim's connected identity's fate (disconnect it) and keep the data. Undermines the documented reasoning that 'allauth only ever matches an address the provider marked verified', which speaks to the provider's side only, not to whether the local copy was ever proven.
Evidence.
Verification (verdict: confirmed; severity after verification: High).
The takeover reproduces end to end with Postulo's real settings. Postulo's can_authenticate_by_email (social_adapter.py:62-78) only checks the operator switch, and allauth's authenticate_by_email (allauth/socialaccount/adapter.py:406-411) looks the local account up with filter_users_by_email(..., prefer_verified=True), which falls back to an unverified EmailAddress row when no verified row exists (allauth/account/internal/userkit.py:137-175). Postulo's /accounts/email/ lets a member add any address as an unverified row (test: user=mallory, verified=False) when no one else holds it verified. allauth's wipe_password (socialaccount/internal/flows/email_authentication.py) is the only mitigation: it makes mallory's password unusable but she still holds a verified address and resets the password herself. No other session ending is installed (INSTALLED_APPS has no allauth.usersessions). I looked for a mitigation and found none:
POSTULO_OIDC_LINK_BY_EMAIL = Falsestops it (control test), a victim who already holds the address verified locally is not affected (control test), and no test or doc in the repository addresses an unverified local row (docs and the social_adapter docstring only discuss the provider's 'verified' claim). Severity high matches the scale (cross-account access needing a precondition).Corrections from verification. Facts and lines are right. Preconditions to state precisely: (1) an OIDC provider is configured and POSTULO_OIDC_LINK_BY_EMAIL is on (it is on by default once OIDC is configured); (2) the attacker is an existing member (the default instance is invite-only); (3) the victim has no local account that holds the address verified, and arrives through the provider with that address marked verified. POSTULO_OIDC_AUTO_SIGNUP does not matter (the pre-added row turns a would-be 'signup closed' into a login). An important aggravation not in the finding: mallory can set her profile name to the victim's, ideally on a fresh sock-puppet account, so the victim lands on what looks like their own new empty account and keeps using it; mallory later regains control through a password reset to her own verified address, and the SocialAccount stays attached to her account. This is the known 'pre-hijacking / trojan identifier' class, which allauth's password wipe does not cover when the attacker keeps another verified address. Remediation: override SocialAccountAdapter.authenticate_by_email so a local account is matched only through a verified EmailAddress row for that address (otherwise treat the address as unknown and continue to normal signup or refuse); additionally consider refusing to add an unverified secondary address for an address that is not yet held by anyone, or not letting an unverified row count as a login match anywhere. Add a tests/security test for the pre-claimed address.
Verification evidence.
Second, refutation-only review (verdict: confirmed). I tried to refute PST-001 and could not. The code path is as the finding describes. Postulo's can_authenticate_by_email (social_adapter.py:62-78) only checks the operator switch (sso.link_by_email(), default True) and then defers to allauth. allauth's authenticate_by_email (socialaccount/adapter.py:397-411) calls filter_users_by_email(email, prefer_verified=True, for_login=True). In account/internal/userkit.py that function keeps unverified EmailAddress rows when no verified row exists, so a member's unverified claim on an address matches. _accept_login then calls wipe_password and, because SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT is on, connects the provider identity to that member's account.
The pre-add step works through the real route. /accounts/email/ accepts the address as an unverified row on the attacker's account when nobody holds it verified. No Postulo code gates that view: addresses.is_offered only decides whether the settings page links to it, and my test shows the POST still returns 302 with the email-addresses feature switched off by policy. Unverified rows are never expired or cleaned up.
The mitigations I looked for do not stop it:
The attacker is "Another account on the same instance", which docs/THREAT-MODEL.md lists as an untrusted attacker. The effect goes beyond anything that actor is trusted with: the victim's later data lands in an account the attacker can get back into.
My own independent scratch test (allauth 65.19.5, Postulo's real settings, SSO via complete_social_login) gave these results:
Suggested remediation. In SocialAccountAdapter.can_authenticate_by_email (or by overriding authenticate_by_email) require that the matching local EmailAddress row is verified before a provider assertion may sign somebody into that account; when the only local copy is unverified, treat the person as new (signup policy applies) and delete or ignore the stale unverified row. Add a tests/test_sso.py case mirroring test_an_address_the_provider_has_not_verified_links_to_nothing for the local side. Combine with the fix to the pre-claim issue so the unverified row cannot persist.
Also covered by this issue.
src/postulo/config/settings/base.py(336-352); Low). Full account takeover of any local account on an instance paired with a weak IdP. The project documents the risk and offers the switch; the finding is that the dangerous value is the default, chosen for backwards compatibility.