diff --git a/apps/api/plane/authentication/adapter/error.py b/apps/api/plane/authentication/adapter/error.py index f91565df2e8..4b53d1cf33b 100644 --- a/apps/api/plane/authentication/adapter/error.py +++ b/apps/api/plane/authentication/adapter/error.py @@ -48,6 +48,7 @@ "GITHUB_OAUTH_PROVIDER_ERROR": 5120, "GITLAB_OAUTH_PROVIDER_ERROR": 5121, "GITEA_OAUTH_PROVIDER_ERROR": 5123, + "OAUTH_PROVIDER_UNVERIFIED_EMAIL": 5124, # Reset Password "INVALID_PASSWORD_TOKEN": 5125, "EXPIRED_PASSWORD_TOKEN": 5130, diff --git a/apps/api/plane/authentication/provider/oauth/gitea.py b/apps/api/plane/authentication/provider/oauth/gitea.py index 8c0c3a5db51..7a1392854d6 100644 --- a/apps/api/plane/authentication/provider/oauth/gitea.py +++ b/apps/api/plane/authentication/provider/oauth/gitea.py @@ -19,7 +19,7 @@ class GiteaOAuthProvider(OauthAdapter): provider = "gitea" - scope = "openid email profile" + scope = "openid email profile read:user" def __init__(self, request, code=None, state=None, callback=None): (GITEA_CLIENT_ID, GITEA_CLIENT_SECRET, GITEA_HOST) = get_configuration_value( @@ -130,15 +130,17 @@ def __get_email(self, headers): error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"], error_message="GITEA_OAUTH_PROVIDER_ERROR: No emails found", ) - # Prefer primary+verified, then any verified, then primary, else first + # Prefer primary+verified, then any verified. Never fall back to an unverified + # email — an attacker with a self-hosted Gitea instance could assert any address + # to take over an existing account (GHSA-7j95-vh8g-f365). email = next((e.get("email") for e in emails_response if e.get("primary") and e.get("verified")), None) if not email: email = next((e.get("email") for e in emails_response if e.get("verified")), None) if not email: - email = next((e.get("email") for e in emails_response if e.get("primary")), None) - if not email and emails_response: - # If no primary email, use the first one - email = emails_response[0].get("email") + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["OAUTH_PROVIDER_UNVERIFIED_EMAIL"], + error_message="OAUTH_PROVIDER_UNVERIFIED_EMAIL", + ) return email except requests.RequestException: raise AuthenticationException( @@ -153,10 +155,10 @@ def set_user_data(self): "Accept": "application/json", } - # Get email if not provided in user info - email = user_info_response.get("email") - if not email: - email = self.__get_email(headers=headers) + # Always use __get_email() which enforces the verified-email requirement. + # The user object's .email field carries no verification flag, so it cannot + # be trusted directly (GHSA-7j95-vh8g-f365). + email = self.__get_email(headers=headers) super().set_user_data( { diff --git a/apps/api/plane/authentication/provider/oauth/github.py b/apps/api/plane/authentication/provider/oauth/github.py index 363cd722e5e..852d8d0f66f 100644 --- a/apps/api/plane/authentication/provider/oauth/github.py +++ b/apps/api/plane/authentication/provider/oauth/github.py @@ -117,12 +117,17 @@ def __get_email(self, headers): error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"], error_message="GITHUB_OAUTH_PROVIDER_ERROR", ) - email = next((email["email"] for email in emails_response if email["primary"]), None) + # Require both primary AND verified — an unverified primary email can be + # exploited to take over an existing account (GHSA-7j95-vh8g-f365). + email = next( + (e["email"] for e in emails_response if e.get("primary") and e.get("verified")), + None, + ) if not email: - self.logger.error("No primary email found for user") + self.logger.error("No primary verified email found for GitHub user") raise AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"], - error_message="GITHUB_OAUTH_PROVIDER_ERROR", + error_code=AUTHENTICATION_ERROR_CODES["OAUTH_PROVIDER_UNVERIFIED_EMAIL"], + error_message="OAUTH_PROVIDER_UNVERIFIED_EMAIL", ) return email except requests.RequestException: diff --git a/apps/api/plane/authentication/provider/oauth/gitlab.py b/apps/api/plane/authentication/provider/oauth/gitlab.py index 088987c2379..7efb9ac94bf 100644 --- a/apps/api/plane/authentication/provider/oauth/gitlab.py +++ b/apps/api/plane/authentication/provider/oauth/gitlab.py @@ -108,6 +108,13 @@ def set_token_data(self): def set_user_data(self): user_info_response = self.get_user_response() + # confirmed_at is null/absent for unverified GitLab accounts. Reject them to + # prevent ATO via self-hosted GitLab with unverified emails (GHSA-7j95-vh8g-f365). + if not user_info_response.get("confirmed_at"): + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["OAUTH_PROVIDER_UNVERIFIED_EMAIL"], + error_message="OAUTH_PROVIDER_UNVERIFIED_EMAIL", + ) email = user_info_response.get("email") super().set_user_data( { diff --git a/apps/api/plane/authentication/provider/oauth/google.py b/apps/api/plane/authentication/provider/oauth/google.py index b02eda87de3..ce15a050651 100644 --- a/apps/api/plane/authentication/provider/oauth/google.py +++ b/apps/api/plane/authentication/provider/oauth/google.py @@ -102,6 +102,14 @@ def set_token_data(self): def set_user_data(self): user_info_response = self.get_user_response() + # Reject unverified emails — an attacker-controlled provider could otherwise assert + # any email to match an existing account (GHSA-7j95-vh8g-f365). Fail closed: treat + # an absent verified_email claim the same as verified_email=false. + if user_info_response.get("verified_email") is not True: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["OAUTH_PROVIDER_UNVERIFIED_EMAIL"], + error_message="OAUTH_PROVIDER_UNVERIFIED_EMAIL", + ) user_data = { "email": user_info_response.get("email"), "user": {