Skip to content

OIDC update path dropped the email-collision guard (two-step account-shadowing bypass) #276

Description

@richardmhope

Summary

The pre-change existing-user path refused a same-subject email change when another local row owned the address (if email_owner is not None and email_owner.id != user.id: raise OIDCIdentityCollisionError). The multi-provider rewrite kept that guard on the new-triple creation path (services/users.py:166-167) but dropped it from the update path — _refresh_profile is now called with any email unconditionally (src/iceberg/services/users.py:180-190).

Failure scenario

The cross-provider relaxation only needed to tolerate bound co-owners (the designed "same person under two IdPs" case). Instead it tolerates unbound owners too, enabling a two-step bypass of the admin-must-link policy:

  1. Attacker with self-service email change at the IdP (normal in Auth0/Authentik) logs in once with a fresh email → new user row created.
  2. Attacker changes their IdP email to a legacy/dev account's address, logs in again → the update path calls _refresh_profile and silently shadows the account that the creation path insists an administrator link explicitly.

Side effect: dev_login for that legacy account then 409s forever (users.py:194-195 blocks when any owner of the email is bound).

Suggested fix

Restore the unbound-owner collision check on the update path: reject an email owned by a different unbound row (bound co-owners remain allowed for the legitimate cross-provider case). Add a test for the email-change-to-claimed-address path.

Found in Fable review of PR #232 → HEAD.

Metadata

Metadata

Assignees

No one assigned

    Labels

    fable-reviewCorrectness findings from Claude Fable code reviewpriority: mediumImportant, not blockingsecuritySecurity hardening / vulnerability

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions