PST-001: Single sign-on links a provider-verified address to a local account that holds it only unverified #730

Closed
opened 2026-10-10 10:55:16 +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-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.


  • Severity: High
  • Category: account-takeover / SSO email linking
  • Location: src/postulo/accounts/social_adapter.py (62-78); src/postulo/config/settings/base.py (336-352)
  • Found by: authentication and sessions, configuration and dependencies
  • Related: PST-002 (#731), PST-003 (#732), PST-007 (#736)

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.

  1. Member mallory adds victim@example.org on /accounts/email/ (stored unverified on her account, see the pre-claim issue).
  2. The victim signs in through the identity provider, which asserts victim@example.org with email_verified=true.
  3. allauth's SocialLogin.lookup → adapter.authenticate_by_email: Postulo's can_authenticate_by_email only asks whether linking is enabled and then defers to allauth, whose filter_users_by_email(prefer_verified=True) falls back to the unverified row and returns mallory.
  4. _accept_login wipes mallory's password and auto-connects the victim's provider identity to mallory's account; the victim is signed in as mallory (confirmed: 'sso status 302 signed in as mallory', User count stays 1, SocialAccount uid 'victim-subject' belongs to mallory).
  5. mallory requests a password reset at her own verified primary mallory@example.org, sets a new password and is back in the shared account ('attacker regains the shared account' assertion passed). allauth.usersessions is not installed, so nothing else ends sessions; the password wipe alone is the only mitigation and it is undone by the reset.

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.

social_adapter.py:62-78 `def can_authenticate_by_email(self, sociallogin, email): ... if not sso.link_by_email(): return False; return super().can_authenticate_by_email(sociallogin, email)` — no check that the local EmailAddress is verified. allauth/socialaccount/adapter.py:406-411 `emails = [e.email for e in sociallogin.email_addresses if e.verified] ... users = filter_users_by_email(email, prefer_verified=True, for_login=True); if users: return users[0], email`. allauth/socialaccount/models.py:396-401 `_accept_login` → `wipe_password(...)` then `self.connect(...)`. allauth/socialaccount/internal/flows/email_authentication.py:26-33 wipes the password only; other sessions end only `if allauth_settings.USERSESSIONS_ENABLED`. base.py:350-352 `POSTULO_OIDC_LINK_BY_EMAIL = env.bool(..., default=True)`; `SOCIALACCOUNT_EMAIL_AUTHENTICATION = POSTULO_OIDC_LINK_BY_EMAIL`; `SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = POSTULO_OIDC_LINK_BY_EMAIL`.

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 = False stops 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.

Scratch test output (verify-accounts-13/test_batch13.py, allauth 65.19.5, complete_social_login as in tests/test_sso.py):
```
pre-added row: user=mallory verified=False primary=False
sso status 302 signed in as mallory
User count 1
SocialAccounts: [{'uid': 'victim-subject', 'user__username': 'mallory', 'provider': 'oidc'}]
mallory usable password after sso: False
mallory regained usable password: True
mallory logs back in as: mallory
```
Controls: `LINK_BY_EMAIL off: status 200 user AnonymousUser` (no capture); `control (victim has verified local acct): signed in as victim`. Also, `add address already held verified: status 302` showed that mallory's unverified row can coexist with another account's verified row, but that case is safe because prefer_verified picks the verified holder. Code: base.py:350-352 `POSTULO_OIDC_LINK_BY_EMAIL = env.bool(..., default=True); SOCIALACCOUNT_EMAIL_AUTHENTICATION = ...; SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = ...`; base.py:60-64 INSTALLED_APPS lists allauth, account, mfa, socialaccount, openid_connect and no usersessions; base.py:243-246 `ACCOUNT_EMAIL_VERIFICATION = 'mandatory'`, `ACCOUNT_UNIQUE_EMAIL = True` (the latter does not stop a first unverified claim).

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:

  • wipe_password makes the password unusable, but the attacker keeps her own verified primary address and can run a normal password reset. The first verifier's test shows she regains a usable password and logs back in.
  • allauth.usersessions is not in INSTALLED_APPS (base.py:60-64), so no other sessions are ended.
  • Mandatory email verification does not block the login, because the attacker has a verified primary address.
  • No test under tests/security or tests/test_sso.py covers an unverified local row. The docs and the social_adapter docstring speak only to the provider's "verified" claim.

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:

  • Baseline with no pre-add and auto-signup ON: the victim gets their own new account 'vic' (2 users).
  • Baseline with no pre-add and auto-signup OFF: the victim is turned away (200, anonymous). …

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.

  • Linking SSO identities to local accounts by provider-asserted email is on by default (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.
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-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. --- - **Severity:** High - **Category:** account-takeover / SSO email linking - **Location:** `src/postulo/accounts/social_adapter.py` (62-78); `src/postulo/config/settings/base.py` (336-352) - **Found by:** authentication and sessions, configuration and dependencies - **Related:** PST-002 (#731), PST-003 (#732), PST-007 (#736) **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.** 1. Member mallory adds victim@example.org on /accounts/email/ (stored unverified on her account, see the pre-claim issue). 2. The victim signs in through the identity provider, which asserts victim@example.org with email_verified=true. 3. allauth's SocialLogin.lookup → adapter.authenticate_by_email: Postulo's can_authenticate_by_email only asks whether linking is enabled and then defers to allauth, whose filter_users_by_email(prefer_verified=True) falls back to the unverified row and returns mallory. 4. _accept_login wipes mallory's password and auto-connects the victim's provider identity to mallory's account; the victim is signed in as mallory (confirmed: 'sso status 302 signed in as mallory', User count stays 1, SocialAccount uid 'victim-subject' belongs to mallory). 5. mallory requests a password reset at her own verified primary mallory@example.org, sets a new password and is back in the shared account ('attacker regains the shared account' assertion passed). allauth.usersessions is not installed, so nothing else ends sessions; the password wipe alone is the only mitigation and it is undone by the reset. **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.** ```text social_adapter.py:62-78 `def can_authenticate_by_email(self, sociallogin, email): ... if not sso.link_by_email(): return False; return super().can_authenticate_by_email(sociallogin, email)` — no check that the local EmailAddress is verified. allauth/socialaccount/adapter.py:406-411 `emails = [e.email for e in sociallogin.email_addresses if e.verified] ... users = filter_users_by_email(email, prefer_verified=True, for_login=True); if users: return users[0], email`. allauth/socialaccount/models.py:396-401 `_accept_login` → `wipe_password(...)` then `self.connect(...)`. allauth/socialaccount/internal/flows/email_authentication.py:26-33 wipes the password only; other sessions end only `if allauth_settings.USERSESSIONS_ENABLED`. base.py:350-352 `POSTULO_OIDC_LINK_BY_EMAIL = env.bool(..., default=True)`; `SOCIALACCOUNT_EMAIL_AUTHENTICATION = POSTULO_OIDC_LINK_BY_EMAIL`; `SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = POSTULO_OIDC_LINK_BY_EMAIL`. ``` **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 = False` stops 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.** ````text Scratch test output (verify-accounts-13/test_batch13.py, allauth 65.19.5, complete_social_login as in tests/test_sso.py): ``` pre-added row: user=mallory verified=False primary=False sso status 302 signed in as mallory User count 1 SocialAccounts: [{'uid': 'victim-subject', 'user__username': 'mallory', 'provider': 'oidc'}] mallory usable password after sso: False mallory regained usable password: True mallory logs back in as: mallory ``` Controls: `LINK_BY_EMAIL off: status 200 user AnonymousUser` (no capture); `control (victim has verified local acct): signed in as victim`. Also, `add address already held verified: status 302` showed that mallory's unverified row can coexist with another account's verified row, but that case is safe because prefer_verified picks the verified holder. Code: base.py:350-352 `POSTULO_OIDC_LINK_BY_EMAIL = env.bool(..., default=True); SOCIALACCOUNT_EMAIL_AUTHENTICATION = ...; SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = ...`; base.py:60-64 INSTALLED_APPS lists allauth, account, mfa, socialaccount, openid_connect and no usersessions; base.py:243-246 `ACCOUNT_EMAIL_VERIFICATION = 'mandatory'`, `ACCOUNT_UNIQUE_EMAIL = True` (the latter does not stop a first unverified claim). ```` **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: - wipe_password makes the password unusable, but the attacker keeps her own verified primary address and can run a normal password reset. The first verifier's test shows she regains a usable password and logs back in. - allauth.usersessions is not in INSTALLED_APPS (base.py:60-64), so no other sessions are ended. - Mandatory email verification does not block the login, because the attacker has a verified primary address. - No test under tests/security or tests/test_sso.py covers an unverified local row. The docs and the social_adapter docstring speak only to the provider's "verified" claim. 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: - Baseline with no pre-add and auto-signup ON: the victim gets their own new account 'vic' (2 users). - Baseline with no pre-add and auto-signup OFF: the victim is turned away (200, anonymous). … **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.** - **Linking SSO identities to local accounts by provider-asserted email is on by default** (`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.
tiagoagueda added this to the 0.5.0 milestone 2026-10-10 10:57:17 +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#730
No description provided.