high · 8.1CVE-2026-55987Jul 21, 2026

CVE-2026-55987: Gitea OAuth2 Sign-In Reactivates Admin-Deactivated Accounts

Pranav Khune
Penetration Testing Team Lead, SecureLayer7

Signing in to Gitea via GitHub OAuth2 (or any OAuth2/OIDC source that does not issue refresh tokens) silently restores an account that an administrator deliberately deactivated, giving the user a…

Packagecode.gitea.io/gitea
Ecosystemgo
Affected< 1.27.0
Fixed in1.27.0

The problem

In routers/web/auth/oauth.go, the handleOAuth2SignIn callback tries to gate reactivation with one check: is the stored refresh token empty? The intent was to detect accounts disabled by the auto-sync cron.

The signal is wrong. For providers like GitHub that never issue refresh tokens, every user's stored refresh token is "", including ones an administrator deactivated by hand. The gate cannot tell those two states apart, so it reactivates all of them. A deactivated account, including a deactivated admin, gets a full session restored on next sign-in.

The hard-ban ProhibitLogin flag is checked separately and is not affected.

Proof of concept

A working proof-of-concept for CVE-2026-55987 in code.gitea.io/gitea, with the exact payload below.

bash
# Prerequisites:
# - Gitea instance with a GitHub OAuth2 authentication source
# - User 'victim' has signed in at least once via GitHub
#   (an external_login_user row exists; refresh_token = "")

# Step 1: Admin deactivates the account
# Admin Panel -> Users -> victim -> uncheck "Activated" -> Save
# Verify: victim's sign-in now bounces to the activation page

# Step 2: Victim clicks "Sign in with GitHub" and completes the OAuth flow
GET /user/oauth2/github
# -> redirects to GitHub -> user authorises -> GitHub calls back:
GET /user/oauth2/github/callback?code=<auth_code>&state=<state>

# Step 3: Gitea checks RefreshToken == "" -> true -> sets IsActive = true
# Victim lands in the app with a live session.

# Confirm in DB:
# SELECT is_active FROM "user" WHERE lower_name='victim';
# Returns: true   (was false before step 2)

The vulnerable gate in routers/web/auth/oauth.go is:

``go isDisabledByAutoSync := hasExt && extLogin.RefreshToken == "" if isDisabledByAutoSync { opts.IsActive = optional.Some(true) } ``

The assumption is that only the auto-sync cron produces an empty refresh token. It is false for two reasons. First, services/auth/source/oauth2/source_sync.go returns early with if !provider.RefreshTokenAvailable() { return ... }, so the cron never touches GitHub users at all.

Second, GitHub's goth provider hardcodes RefreshTokenAvailable() bool { return false }, so no GitHub user ever has a refresh token stored. The stored "" is written at first login by services/externalaccount/user.go, which saves exactly what the provider returned.

The fix (PR #38406) removes the RefreshToken == "" signal and instead gates reactivation on whether the provider can ever produce refresh tokens. If RefreshTokenAvailable() is false for the source, no reactivation is allowed, because the cron could never have been the one to disable the account.

CWE-863 (Incorrect Authorization) applies: the authorization decision relies on a condition that is trivially true for the wrong class of users.

The fix

Upgrade to Gitea 1.27.0 (PR #38406). The 1.26.x backport is in 1.26.4 (PR #38183). If you cannot upgrade immediately, switch affected users from the "Activated" toggle to "Prohibit Login", which is enforced by a separate, unaffected code path and will keep blocking access until you patch.

Reported by khoadb175.

References: [1][2][3]

Related research