Skip to content

Commit b91b61c

Browse files
mguptahubPlane AI
andauthored
[WEB-7778] fix(security): reject unverified OAuth provider emails to prevent ATO (Cluster E) (#9289)
* [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 <noreply@plane.so> * fix(security): add read:user scope to Gitea; fail-closed on absent Google verified_email Gitea's /api/v1/user/emails endpoint requires the read:user granular scope — openid+email+profile alone is insufficient and __get_email() would return a 401/403. Add read:user to the scope string. Google: change default from True to fail-closed (is not True) so a userinfo response that omits verified_email is rejected rather than trusted. The service-account justification was incorrect — service accounts do not go through the interactive OAuth2 callback flow. Co-authored-by: Plane AI <noreply@plane.so> --------- Co-authored-by: Plane AI <noreply@plane.so>
1 parent 4fc79a2 commit b91b61c

5 files changed

Lines changed: 37 additions & 14 deletions

File tree

apps/api/plane/authentication/adapter/error.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"GITHUB_OAUTH_PROVIDER_ERROR": 5120,
5050
"GITLAB_OAUTH_PROVIDER_ERROR": 5121,
5151
"GITEA_OAUTH_PROVIDER_ERROR": 5123,
52+
"OAUTH_PROVIDER_UNVERIFIED_EMAIL": 5124,
5253
# Reset Password
5354
"INVALID_PASSWORD_TOKEN": 5125,
5455
"EXPIRED_PASSWORD_TOKEN": 5130,

apps/api/plane/authentication/provider/oauth/gitea.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
class GiteaOAuthProvider(OauthAdapter):
2121
provider = "gitea"
22-
scope = "openid email profile"
22+
scope = "openid email profile read:user"
2323

2424
def __init__(self, request, code=None, state=None, callback=None):
2525
(GITEA_CLIENT_ID, GITEA_CLIENT_SECRET, GITEA_HOST) = get_configuration_value(
@@ -130,15 +130,17 @@ def __get_email(self, headers):
130130
error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"],
131131
error_message="GITEA_OAUTH_PROVIDER_ERROR: No emails found",
132132
)
133-
# Prefer primary+verified, then any verified, then primary, else first
133+
# Prefer primary+verified, then any verified. Never fall back to an unverified
134+
# email — an attacker with a self-hosted Gitea instance could assert any address
135+
# to take over an existing account (GHSA-7j95-vh8g-f365).
134136
email = next((e.get("email") for e in emails_response if e.get("primary") and e.get("verified")), None)
135137
if not email:
136138
email = next((e.get("email") for e in emails_response if e.get("verified")), None)
137139
if not email:
138-
email = next((e.get("email") for e in emails_response if e.get("primary")), None)
139-
if not email and emails_response:
140-
# If no primary email, use the first one
141-
email = emails_response[0].get("email")
140+
raise AuthenticationException(
141+
error_code=AUTHENTICATION_ERROR_CODES["OAUTH_PROVIDER_UNVERIFIED_EMAIL"],
142+
error_message="OAUTH_PROVIDER_UNVERIFIED_EMAIL",
143+
)
142144
return email
143145
except requests.RequestException:
144146
raise AuthenticationException(
@@ -153,10 +155,10 @@ def set_user_data(self):
153155
"Accept": "application/json",
154156
}
155157

156-
# Get email if not provided in user info
157-
email = user_info_response.get("email")
158-
if not email:
159-
email = self.__get_email(headers=headers)
158+
# Always use __get_email() which enforces the verified-email requirement.
159+
# The user object's .email field carries no verification flag, so it cannot
160+
# be trusted directly (GHSA-7j95-vh8g-f365).
161+
email = self.__get_email(headers=headers)
160162

161163
super().set_user_data(
162164
{

apps/api/plane/authentication/provider/oauth/github.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,17 @@ def __get_email(self, headers):
117117
error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"],
118118
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
119119
)
120-
email = next((email["email"] for email in emails_response if email["primary"]), None)
120+
# Require both primary AND verified — an unverified primary email can be
121+
# exploited to take over an existing account (GHSA-7j95-vh8g-f365).
122+
email = next(
123+
(e["email"] for e in emails_response if e.get("primary") and e.get("verified")),
124+
None,
125+
)
121126
if not email:
122-
self.logger.error("No primary email found for user")
127+
self.logger.error("No primary verified email found for GitHub user")
123128
raise AuthenticationException(
124-
error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"],
125-
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
129+
error_code=AUTHENTICATION_ERROR_CODES["OAUTH_PROVIDER_UNVERIFIED_EMAIL"],
130+
error_message="OAUTH_PROVIDER_UNVERIFIED_EMAIL",
126131
)
127132
return email
128133
except requests.RequestException:

apps/api/plane/authentication/provider/oauth/gitlab.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,13 @@ def set_token_data(self):
108108

109109
def set_user_data(self):
110110
user_info_response = self.get_user_response()
111+
# confirmed_at is null/absent for unverified GitLab accounts. Reject them to
112+
# prevent ATO via self-hosted GitLab with unverified emails (GHSA-7j95-vh8g-f365).
113+
if not user_info_response.get("confirmed_at"):
114+
raise AuthenticationException(
115+
error_code=AUTHENTICATION_ERROR_CODES["OAUTH_PROVIDER_UNVERIFIED_EMAIL"],
116+
error_message="OAUTH_PROVIDER_UNVERIFIED_EMAIL",
117+
)
111118
email = user_info_response.get("email")
112119
super().set_user_data(
113120
{

apps/api/plane/authentication/provider/oauth/google.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,14 @@ def set_token_data(self):
102102

103103
def set_user_data(self):
104104
user_info_response = self.get_user_response()
105+
# Reject unverified emails — an attacker-controlled provider could otherwise assert
106+
# any email to match an existing account (GHSA-7j95-vh8g-f365). Fail closed: treat
107+
# an absent verified_email claim the same as verified_email=false.
108+
if user_info_response.get("verified_email") is not True:
109+
raise AuthenticationException(
110+
error_code=AUTHENTICATION_ERROR_CODES["OAUTH_PROVIDER_UNVERIFIED_EMAIL"],
111+
error_message="OAUTH_PROVIDER_UNVERIFIED_EMAIL",
112+
)
105113
user_data = {
106114
"email": user_info_response.get("email"),
107115
"user": {

0 commit comments

Comments
 (0)