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 full
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