From 36cef21b05d92e1522d0eae4052c1c335d9839d2 Mon Sep 17 00:00:00 2001 From: Manish Gupta Date: Mon, 22 Jun 2026 16:27:39 +0530 Subject: [PATCH] [WEB-7778] fix(security): reject unverified OAuth provider emails to prevent ATO An attacker controlling a self-hosted OAuth provider (Gitea, GitLab) could assert any email address in the OAuth response and be matched to an existing Plane account, bypassing authentication entirely. - Add OAUTH_PROVIDER_UNVERIFIED_EMAIL (5124) error code - GitHub: require both primary=True AND verified=True on email (was primary-only) - Google: check verified_email=False field in userinfo response - GitLab: check confirmed_at is non-null before accepting email - Gitea __get_email: remove unverified fallbacks (primary-unverified, any-unverified) - Gitea set_user_data: remove fast-path using .email from user object (no verification flag); always go through __get_email() which enforces verified Fixes GHSA-7j95-vh8g-f365 (critical ATO). Note: GHSA-cv9p-325g-wmv5 and GHSA-hx79-5pj5-qh42 (avatar SSRF) were already fixed in PR #9163. Co-authored-by: Plane AI --- .../api/plane/authentication/adapter/error.py | 1 + .../authentication/provider/oauth/gitea.py | 20 ++++++++++--------- .../authentication/provider/oauth/github.py | 13 ++++++++---- .../authentication/provider/oauth/gitlab.py | 7 +++++++ .../authentication/provider/oauth/google.py | 8 ++++++++ 5 files changed, 36 insertions(+), 13 deletions(-) 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..ba9119194d1 100644 --- a/apps/api/plane/authentication/provider/oauth/gitea.py +++ b/apps/api/plane/authentication/provider/oauth/gitea.py @@ -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..43572ae9b2d 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). Default True so + # service accounts that omit the field are not broken. + if not user_info_response.get("verified_email", 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": {