[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 (Cluster E)#9289mguptahub wants to merge 1 commit into
Conversation
…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>
|
Linked to Plane Work Item(s) This comment was auto-generated by Plane |
📝 WalkthroughWalkthroughAdds ChangesOAuth Unverified Email Rejection
Estimated code review effort🎯 2 (Simple) | ⏱️ ~10 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (5)
apps/api/plane/authentication/adapter/error.pyapps/api/plane/authentication/provider/oauth/gitea.pyapps/api/plane/authentication/provider/oauth/github.pyapps/api/plane/authentication/provider/oauth/gitlab.pyapps/api/plane/authentication/provider/oauth/google.py
| # 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) |
There was a problem hiding this comment.
🧩 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:
- 1: https://github.com/go-gitea/gitea/blob/main/routers/api/v1/api.go
- 2: https://github.com/go-gitea/gitea/blob/cfd72183/routers/api/v1/api.go
- 3: https://docs.gitea.com/development/oauth2-provider
- 4: Enhancing Gitea OAuth2 Provider with Granular Scopes for Resource Access go-gitea/gitea#31609
- 5: https://docs.gitea.com/1.26/development/oauth2-provider
- 6: https://docs.gitea.com/next/development/oauth2-provider
🏁 Script executed:
# Find the Gitea OAuth provider configuration
cd apps/api && find . -name "*.py" -type f | xargs grep -l "gitea" | head -20Repository: 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 pyRepository: 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.pyRepository: 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.pyRepository: 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.
| # 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): |
There was a problem hiding this comment.
🧩 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:
- 1: https://developers.google.cn/identity/openid-connect/reference
- 2: https://developers.google.com/identity/openid-connect/reference
- 3: https://developers.google.com/identity/openid-connect/openid-connect
- 4: https://googleapis.dev/nodejs/googleapis/latest/oauth2/interfaces/Schema$Userinfo.html
- 5: https://googleapis.github.io/google-api-python-client/docs/dyn/oauth2_v2.userinfo.v2.me.html
- 6: https://googleapis.github.io/google-api-python-client/docs/dyn/oauth2_v2.userinfo.html
- 7: https://googleapis.dev/nodejs/googleapis/latest/oauth2/classes/Resource$Userinfo$V2$Me.html
- 8: https://developers.google.com/identity/sign-in/web/backend-auth
- 9: https://developers.google.cn/identity/gsi/web/guides/verify-google-id-token
🏁 Script executed:
git log --oneline -n 20 -- apps/api/plane/authentication/provider/oauth/google.pyRepository: makeplane/plane
Length of output: 150
🏁 Script executed:
rg "verified_email" --type py -B 3 -A 3Repository: makeplane/plane
Length of output: 948
🏁 Script executed:
find apps/api/plane/authentication/provider/oauth -name "*.py" -type f | head -10Repository: makeplane/plane
Length of output: 335
🏁 Script executed:
rg "def set_user_data" apps/api/plane/authentication/provider/oauth/ -A 10Repository: 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:
- 1: https://developers.google.cn/identity/openid-connect/reference
- 2: https://stackoverflow.com/questions/62527707/no-email-or-email-verified-claim-in-google-id-token
- 3: https://developers.google.com/identity/openid-connect/reference?hl=id
- 4: https://googleapis.dev/nodejs/googleapis/latest/oauth2/classes/Resource$Userinfo$V2$Me.html
- 5: https://stackoverflow.com/questions/15073386/does-googles-userinfo-api-offer-any-guarantees
- 6: https://googleapis.dev/nodejs/googleapis/latest/oauth2/interfaces/Schema$Userinfo.html
- 7: https://googleapis.github.io/google-api-python-client/docs/dyn/oauth2_v2.userinfo.v2.me.html
- 8: https://googleapis.github.io/google-api-python-client/docs/dyn/oauth2_v2.userinfo.html
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.
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
adapter/error.pyOAUTH_PROVIDER_UNVERIFIED_EMAILerror code (5124)provider/oauth/github.pyprimary=Trueandverified=True(wasprimary-only)provider/oauth/google.pyverified_emailfield in userinfo responseprovider/oauth/gitlab.pyconfirmed_atis non-null before accepting emailprovider/oauth/gitea.py__get_email(); remove fast path inset_user_data()that bypassed verificationAll providers now fail closed — if no verified email is available,
AuthenticationException(OAUTH_PROVIDER_UNVERIFIED_EMAIL)is raised and login is rejected.Test plan
OAUTH_PROVIDER_UNVERIFIED_EMAILverified_email: true— succeedsverified_email: false(mocked) — rejectedconfirmed_atset — succeedsconfirmed_at: null(mocked self-hosted) — rejectedOAUTH_PROVIDER_UNVERIFIED_EMAILFixes GHSA-7j95-vh8g-f365 (critical).
Co-authored-by: Plane AI noreply@plane.so
Summary by CodeRabbit