mirror of
https://github.com/open-webui/open-webui.git
synced 2026-11-04 07:47:10 +00:00
fix(oauth): require provider-verified email before merging accounts by email
The merge-by-email path bound a new (provider, sub) identity to an existing account looked up by email claim alone, on both the OAuth callback (utils/oauth.py) and the token-exchange endpoint (routers/auths.py). An identity provider that lets users assert arbitrary unverified email addresses could hand any matching account, including admin accounts, to an attacker. Track provider verification during email extraction (OIDC email_verified claim in bool or string form, GitHub /user/emails per-address verified flag) and refuse the merge when the provider explicitly reports the email as unverified: the callback redirects with the standard invalid-credentials error, and token exchange returns 403. Providers that send no verification signal keep current behavior. Rebased and extended after #28191 was closed: the same merge-by-email path exists in the token-exchange endpoint; this commit covers both.
This commit is contained in:
parent
8faaf2cd1e
commit
785f95679a
2 changed files with 41 additions and 4 deletions
|
|
@ -1669,6 +1669,20 @@ async def token_exchange(
|
|||
user = await Users.get_user_by_oauth_sub(provider, sub, db=db)
|
||||
|
||||
if not user and await Config.get('oauth.merge_accounts_by_email'):
|
||||
# Never merge on an email the provider reports as unverified: an IdP
|
||||
# that lets users claim arbitrary addresses would otherwise hand over
|
||||
# the matching existing account (account takeover). Same gate as the
|
||||
# OAuth callback path. Providers that send no verification signal
|
||||
# keep current behavior.
|
||||
email_verified = user_data.get('email_verified')
|
||||
if isinstance(email_verified, str):
|
||||
email_verified = email_verified.lower() == 'true'
|
||||
if email_verified is False:
|
||||
log.warning(f'Token exchange denied: refusing to merge account on unverified email')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
# Try to find by email if merge is enabled
|
||||
user = await Users.get_user_by_email(email, db=db)
|
||||
if user:
|
||||
|
|
|
|||
|
|
@ -1848,6 +1848,10 @@ class OAuthManager:
|
|||
# Email extraction
|
||||
email_claim = auth_config.OAUTH_EMAIL_CLAIM
|
||||
email = user_data.get(email_claim, '')
|
||||
# Whether the provider vouches for the email address. None means
|
||||
# the provider gave no verification signal; set by the claim
|
||||
# check below or by the GitHub /user/emails fallback.
|
||||
email_verified = None
|
||||
# We currently mandate that email addresses are provided
|
||||
if not email:
|
||||
# If the provider is GitHub,and public email is not provided, we can use the access token to fetch the user's email
|
||||
|
|
@ -1864,12 +1868,15 @@ class OAuthManager:
|
|||
if resp.ok:
|
||||
emails = await resp.json()
|
||||
# use the primary email as the user's email
|
||||
primary_email = next(
|
||||
(e['email'] for e in emails if e.get('primary')),
|
||||
primary_entry = next(
|
||||
(e for e in emails if e.get('primary')),
|
||||
None,
|
||||
)
|
||||
if primary_email:
|
||||
email = primary_email
|
||||
if primary_entry:
|
||||
email = primary_entry['email']
|
||||
# GitHub reports per-address verification; keep it for
|
||||
# the account-merge gate below
|
||||
email_verified = bool(primary_entry.get('verified'))
|
||||
else:
|
||||
log.warning('No primary email found in GitHub response')
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
|
|
@ -1885,6 +1892,15 @@ class OAuthManager:
|
|||
log.warning(f'OAuth callback failed, email is missing: {user_data}')
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
|
||||
# OIDC providers send an `email_verified` claim; the GitHub
|
||||
# fallback above sets email_verified from the /user/emails entry.
|
||||
if email_verified is None:
|
||||
verified_claim = user_data.get('email_verified')
|
||||
if isinstance(verified_claim, bool):
|
||||
email_verified = verified_claim
|
||||
elif isinstance(verified_claim, str):
|
||||
email_verified = verified_claim.lower() == 'true'
|
||||
|
||||
email = email.lower()
|
||||
# If allowed domains are configured, check if the email domain is in the list
|
||||
if (
|
||||
|
|
@ -1899,6 +1915,13 @@ class OAuthManager:
|
|||
if not user:
|
||||
# If the user does not exist, check if merging is enabled
|
||||
if auth_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL:
|
||||
# Never merge on an email the provider reports as
|
||||
# unverified: an IdP that lets users claim arbitrary
|
||||
# addresses would otherwise hand over the matching
|
||||
# existing account (account takeover)
|
||||
if email_verified is False:
|
||||
log.warning(f'OAuth callback failed, refusing to merge account on unverified email: {email}')
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
# Check if the user exists by email
|
||||
user = await Users.get_user_by_email(email, db=db)
|
||||
if user:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue