Skip to content

[WEB-7778] fix(security): reject unverified OAuth provider emails to prevent ATO (Cluster E)#9289

Open
mguptahub wants to merge 1 commit into
previewfrom
web-7778/fix-oauth-unverified-email-ato
Open

[WEB-7778] fix(security): reject unverified OAuth provider emails to prevent ATO (Cluster E)#9289
mguptahub wants to merge 1 commit into
previewfrom
web-7778/fix-oauth-unverified-email-ato

Conversation

@mguptahub

@mguptahub mguptahub commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

OAuth providers can return unverified email addresses. The current code accepted them and used them to look up existing Plane accounts — allowing an attacker who controls a self-hosted Gitea or GitLab instance to assert any email and gain access to another user's account (account takeover).

GHSA-cv9p-325g-wmv5 and GHSA-hx79-5pj5-qh42 (avatar SSRF) were already fixed in PR #9163 and are not part of this change.

Changes

File Fix
adapter/error.py Add OAUTH_PROVIDER_UNVERIFIED_EMAIL error code (5124)
provider/oauth/github.py Require primary=True and verified=True (was primary-only)
provider/oauth/google.py Check verified_email field in userinfo response
provider/oauth/gitlab.py Check confirmed_at is non-null before accepting email
provider/oauth/gitea.py Remove unverified fallbacks in __get_email(); remove fast path in set_user_data() that bypassed verification

All providers now fail closed — if no verified email is available, AuthenticationException(OAUTH_PROVIDER_UNVERIFIED_EMAIL) is raised and login is rejected.

Test plan

  • GitHub login with a verified primary email — succeeds
  • GitHub login where primary email is unverified — rejected with OAUTH_PROVIDER_UNVERIFIED_EMAIL
  • Google login with verified_email: true — succeeds
  • Google login with verified_email: false (mocked) — rejected
  • GitLab login with confirmed_at set — succeeds
  • GitLab login with confirmed_at: null (mocked self-hosted) — rejected
  • Gitea login with verified email — succeeds
  • Gitea login where all emails are unverified — rejected with OAUTH_PROVIDER_UNVERIFIED_EMAIL
  • Gitea login where only an unverified primary email exists (no verified fallback) — rejected

Fixes GHSA-7j95-vh8g-f365 (critical).

Co-authored-by: Plane AI noreply@plane.so

Summary by CodeRabbit

  • Bug Fixes
    • OAuth authentication now requires verified email addresses across GitHub, GitLab, Google, and Gitea providers.
    • Users attempting to sign in with unverified email addresses will receive an error and unable to proceed with authentication.

…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>
Copilot AI review requested due to automatic review settings June 22, 2026 10:58

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@makeplane

makeplane Bot commented Jun 22, 2026

Copy link
Copy Markdown

Linked to Plane Work Item(s)

This comment was auto-generated by Plane

@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds OAUTH_PROVIDER_UNVERIFIED_EMAIL (code 5124) to the authentication error dictionary, then enforces verified-email checks in all four OAuth providers: GitHub, GitLab, Gitea, and Google now raise AuthenticationException with that error code when no verified email can be confirmed from the provider.

Changes

OAuth Unverified Email Rejection

Layer / File(s) Summary
New OAUTH_PROVIDER_UNVERIFIED_EMAIL error code
apps/api/plane/authentication/adapter/error.py
Adds "OAUTH_PROVIDER_UNVERIFIED_EMAIL": 5124 to AUTHENTICATION_ERROR_CODES, making the error code available for all providers.
Verified-email guards in all four OAuth providers
apps/api/plane/authentication/provider/oauth/github.py, apps/api/plane/authentication/provider/oauth/gitea.py, apps/api/plane/authentication/provider/oauth/gitlab.py, apps/api/plane/authentication/provider/oauth/google.py
GitHub's __get_email now requires both primary and verified flags and raises OAUTH_PROVIDER_UNVERIFIED_EMAIL on failure. Gitea's __get_email drops unverified fallbacks and set_user_data always calls __get_email. GitLab's set_user_data rejects accounts missing confirmed_at. Google's set_user_data rejects responses where verified_email is falsy.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

🐇 Hop hop, no sneaky mail shall pass,
Unless it's verified, first-class!
Each provider checked, no unverified sneak,
Error 5124 guards every beak.
The warren is safe — only true emails speak! ✉️

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main security fix: rejecting unverified OAuth provider emails to prevent account takeover attacks.
Description check ✅ Passed The description comprehensively covers the vulnerability, changes made to each provider, error handling, and includes a detailed test plan, but is missing the structured template sections.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch web-7778/fix-oauth-unverified-email-ato

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

🤖 Prompt for all review comments with 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.

Inline comments:
In `@apps/api/plane/authentication/provider/oauth/gitea.py`:
- Around line 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.

In `@apps/api/plane/authentication/provider/oauth/google.py`:
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c5a09811-d7c3-49cf-aae3-80f2fe6b0daf

📥 Commits

Reviewing files that changed from the base of the PR and between 4a0746b and 36cef21.

📒 Files selected for processing (5)
  • apps/api/plane/authentication/adapter/error.py
  • apps/api/plane/authentication/provider/oauth/gitea.py
  • apps/api/plane/authentication/provider/oauth/github.py
  • apps/api/plane/authentication/provider/oauth/gitlab.py
  • apps/api/plane/authentication/provider/oauth/google.py

Comment on lines +158 to +161
# 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)

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.

Comment on lines +105 to +108
# 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):

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants