CVE-2026-55987: Gitea OAuth2 Sign-In Reactivates Admin-Deactivated Accounts
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…
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.
# 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.
Related research
- high · 8.8CVE-2026-27775CVE-2026-27775: Gitea Pre-Receive Hook Authorization Bypass via Cached Branch Permission
- high · 8.9CVE-2026-58424CVE-2026-58424: Gitea Actions Fork PR Approval Gate Permanent Bypass
- high · 8.1CVE-2026-58439CVE-2026-58439: Gitea Branch Protection Bypass via PR Retargeting
- high · 7.5CVE-2026-58419CVE-2026-58419: Gitea Notification API Private Issue Metadata Leak After Access Revocation