Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/plane/authentication/adapter/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 11 additions & 9 deletions apps/api/plane/authentication/provider/oauth/gitea.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
Comment on lines +158 to +161

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

For the Gitea versions supported by Plane, does an OAuth2 access token requested with only openid email profileauthorizeGET /api/v1/user/emails, or is a granular user scope such as read:user required?

💡 Result:

No, an OAuth2 access token requested with only the openid, email, and profile scopes does not authorize access to the GET /api/v1/user/emails endpoint in Gitea. In Gitea’s API routing, the /api/v1/user/emails endpoint is protected and requires a token with the user scope (specifically, the user scope category) [1][2]. The openid, email, and profile scopes are standard OIDC scopes intended for identity and basic profile information (such as user ID, email address, and name) [3][4]. They do not grant authorization for Gitea's administrative or resource-specific API endpoints [1][2]. To successfully call GET /api/v1/user/emails, the OAuth2 client must explicitly request a granular scope that includes user-level access (e.g., read:user) [5][6][4]. While Gitea has evolved to support granular scopes for OAuth2, these must be requested during the authorization flow, and the openid, email, and profile scopes are insufficient to bypass the authorization checks required for /api/v1/user/emails [5][3][1].

Citations:


🏁 Script executed:

# Find the Gitea OAuth provider configuration
cd apps/api && find . -name "*.py" -type f | xargs grep -l "gitea" | head -20

Repository: makeplane/plane

Length of output: 481


🏁 Script executed:

# Search for scope definitions in the gitea provider file and related files
rg -A 5 -B 5 "scope.*=|SCOPE" apps/api/plane/authentication/provider/oauth/ -t py

Repository: makeplane/plane

Length of output: 5170


🏁 Script executed:

# Check the full gitea.py file to understand the current scope configuration
wc -l apps/api/plane/authentication/provider/oauth/gitea.py

Repository: makeplane/plane

Length of output: 117


🏁 Script executed:

# Read the gitea.py file to see the __get_email() implementation
cat -n apps/api/plane/authentication/provider/oauth/gitea.py

Repository: makeplane/plane

Length of output: 8390


Add read:user scope to Gitea OAuth configuration.

The __get_email() method at line 161 calls GET /api/v1/user/emails, which requires the read:user granular scope in Gitea's OAuth2. The current scope set (openid email profile) does not authorize this endpoint—these are standard OIDC scopes for identity and basic profile info only. Update line 22 to include the required scope: scope = "openid email profile read:user", or switch to an OIDC email_verified claim if available in your Gitea version to avoid the additional API call.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/plane/authentication/provider/oauth/gitea.py` around lines 158 -
161, The __get_email() method calls the Gitea API endpoint GET
/api/v1/user/emails, which requires the read:user OAuth2 scope. The current
scope configuration in the Gitea OAuth provider (around line 22) only includes
standard OIDC scopes (openid email profile) and lacks the necessary read:user
scope. Update the scope variable in the Gitea OAuth configuration to include the
read:user scope by adding it to the existing scope string, changing it from
"openid email profile" to "openid email profile read:user", so that the
__get_email() method can successfully authenticate and retrieve verified email
information.


super().set_user_data(
{
Expand Down
13 changes: 9 additions & 4 deletions apps/api/plane/authentication/provider/oauth/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions apps/api/plane/authentication/provider/oauth/gitlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down
8 changes: 8 additions & 0 deletions apps/api/plane/authentication/provider/oauth/google.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Comment on lines +105 to +108

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n apps/api/plane/authentication/provider/oauth/google.py | sed -n '95,120p'

Repository: makeplane/plane

Length of output: 1555


🌐 Web query:

Google OAuth2 userinfo endpoint verified_email field documentation

💡 Result:

The standard field for verifying an email address in the Google UserInfo endpoint is email_verified, which is a boolean [1][2][3]. This field indicates whether the user's email address has been verified by Google [1][2][3]. Some legacy or specific API client library schemas may reference a field named verified_email [4][5][6][7]. Documentation for these specific library interfaces notes that verified_email functions as a boolean flag that is true if the email address is verified [4][7]. For modern OpenID Connect implementations with Google, you should use the email_verified claim as defined in the OpenID Connect standard [1][2][3]. You can determine if Google is authoritative for an email address by checking email_verified in conjunction with other fields like hd (hosted domain) [8][9]. Always use the sub field as the unique identifier for a user rather than the email address, as the latter may change over time [2][9].

Citations:


🏁 Script executed:

git log --oneline -n 20 -- apps/api/plane/authentication/provider/oauth/google.py

Repository: makeplane/plane

Length of output: 150


🏁 Script executed:

rg "verified_email" --type py -B 3 -A 3

Repository: makeplane/plane

Length of output: 948


🏁 Script executed:

find apps/api/plane/authentication/provider/oauth -name "*.py" -type f | head -10

Repository: makeplane/plane

Length of output: 335


🏁 Script executed:

rg "def set_user_data" apps/api/plane/authentication/provider/oauth/ -A 10

Repository: makeplane/plane

Length of output: 4645


🌐 Web query:

Google OAuth2 userinfo endpoint API always returns verified_email field mandatory optional

💡 Result:

The verified_email field (often represented as email_verified in OpenID Connect) in the Google OAuth2 userinfo endpoint is not strictly mandatory in the sense that it is always guaranteed to be present in every response, though it is standard when the appropriate scopes are requested [1][2][3]. Specifically, the following details apply: 1. Scope Dependency: The field is generally included when you request the email scope [1][4][3]. Official documentation states that if the email scope value is present, the response (or ID Token) includes the email and email_verified claims [1][3]. 2. Availability: While it is standard, authoritative sources and community discussions indicate that developers should not treat these claims as guaranteed in every possible scenario [5][2]. It is best practice to design your application to handle cases where these fields might be absent [5][2]. 3. Terminology: Be aware that different Google APIs use slightly different naming conventions. The OpenID Connect UserInfo endpoint typically uses the field name email_verified [1][3], while some older Google OAuth2 v2 API client libraries and documentation refer to it as verified_email [6][7][8]. In summary, while you should expect verified_email/email_verified when requesting the email scope, it should be treated as optional or conditional in your application logic to ensure robustness [5][2]. Always rely on the sub (subject) field as the stable, unique identifier for the user [1][3].

Citations:


Require explicit verification signal; reject when Google omits verified_email.

Line 108 treats a missing verification claim as trusted with the True default, allowing responses without verified_email to proceed to account lookup. This violates fail-closed principle for handling unverified emails. While the code comment cites service account compatibility, Google's userinfo API sends verified_email (or email_verified in OpenID Connect) as standard for normal user flows when the email scope is requested; its absence should be treated as unverified, not trusted.

Suggested fix
-        # 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):
+        # Reject unverified emails — an attacker-controlled provider could otherwise assert
+        # any email to match an existing account (GHSA-7j95-vh8g-f365).
+        if user_info_response.get("verified_email") is not True:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/plane/authentication/provider/oauth/google.py` around lines 105 -
108, The email verification check in the Google OAuth provider is using a
default value of True when the verified_email field is missing, which treats
absent verification claims as trusted and violates the fail-closed security
principle. Change the default parameter in the
user_info_response.get("verified_email", True) call from True to False so that
when Google omits the verified_email field, the email is properly treated as
unverified and rejected, requiring explicit verification signals to proceed.

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": {
Expand Down
Loading