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 full

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