VDB
KO
HIGH 8.1

GHSA-vrhc-jjfc-m3m3

Gitea: OAuth2 sign-in reactivates an administrator-deactivated account on auth sources without refresh tokens (incomplete fix of #38009)

Quick fix

GHSA-vrhc-jjfc-m3m3 — code.gitea.io/gitea: upgrade to the fixed version with the command below.

go get code.gitea.io/gitea@v1.27.0

Details

## Description

Gitea's OAuth2 sign-in callback reactivates a deactivated user account (`IsActive=false`) when the user signs in through an authentication source that does not issue refresh tokens (notably GitHub, and any OIDC/OAuth2 source configured without `offline_access`). PR #38009 added a gate intended to reactivate users only when the OAuth2 auto-sync cron had disabled them, using "the stored refresh token is empty" as the signal. That signal is wrong: for sources that never issue refresh tokens, an empty refresh token is the normal state of every user, so the gate cannot distinguish a cron-disabled account from one an administrator deliberately deactivated. The next time the administrator-deactivated user signs in through the provider, Gitea sets `IsActive=true` and grants a full session, silently undoing the administrator's action. This is the exact behavior #38009 was written to prevent. (`ProhibitLogin`, the hard ban, is enforced separately and is not affected.)

No special privileges are required beyond being the deactivated user and being able to sign in through the source.

### Root Cause

`routers/web/auth/oauth.go` (the `handleOAuth2SignIn` reactivation gate):

```go if !u.IsActive { extLogin, hasExt, err := user_model.GetExternalLogin(ctx, authSource.ID, gothUser.UserID) if err != nil { ctx.ServerError("GetExternalLogin", err); return } isDisabledByAutoSync := hasExt && extLogin.RefreshToken == "" // wrong signal if isDisabledByAutoSync { opts.IsActive = optional.Some(true) // reactivates the account } } ```

The assumption that `RefreshToken == ""` is produced only by the auto-sync cron is false:

- The cron's disable path is unreachable for sources without refresh tokens. `services/auth/source/oauth2/source_sync.go` returns early: `if !provider.RefreshTokenAvailable() { return ... }`, so it never disables (or touches the tokens of) such users. - The stored token is exactly what the provider returned, with no synthesizing: `services/externalaccount/user.go` stores `RefreshToken: gothUser.RefreshToken`. When the provider issues none, this is `""` from the first login. - GitHub never issues a refresh token: `goth` hardcodes `func (p *Provider) RefreshTokenAvailable() bool { return false }` (`providers/github/github.go`). OIDC/OAuth2 without `offline_access` likewise store `""`.

So for a GitHub (or no-refresh-token) source, `RefreshToken == ""` is the state of every user, including one an administrator deactivated, and the gate reactivates them.

### Proof of Concept

Setup: - A Gitea instance with a GitHub authentication source (Admin Panel -> Authentication Sources -> OAuth2 -> GitHub), or any OAuth2/OIDC source configured without `offline_access`. - Account V: a normal user who has signed in at least once through that source (an `external_login_user` row exists with empty `refresh_token`).

Steps: 1. As an administrator, open Admin Panel -> Users -> V and uncheck "Activated" (`is_active=false`). Confirm V's requests now bounce to the activation page. 2. As V, sign in again via "Sign in with GitHub" and complete the provider flow. 3. V lands in the application with a working session. `SELECT is_active FROM "user" WHERE lower_name='v';` now returns `true`.

Expected (intended by #38009): V stays `is_active=false` and is routed to the activation page. Actual: V is `is_active=true` with a full session — the administrator's deactivation is undone.

``` - GitHub user, ADMIN deactivated refreshToken="" -> REACTIVATED + session granted <<< admin action undone - OIDC user w/ refresh token, ADMIN deactivated refreshToken="..." -> stays disabled (control) - OIDC user, AUTO-SYNC cron disabled refreshToken="" -> REACTIVATED (intended) RESULT: BYPASS CONFIRMED. ```

Gitea's own regression test `TestOAuth2CallbackReactivationGating` ("auto-sync-disabled user is reactivated") sets `RefreshToken=""` and asserts reactivation after a full OIDC callback — that state is identical to a GitHub-source user an administrator deactivated.

### Impact

Any Gitea instance using a GitHub authentication source (one of the most common) or an OIDC/OAuth2 source without refresh tokens, that relies on the "Activated" toggle to disable accounts, is affected. A deactivated user restores their own account to active and obtains a session, regaining whatever access the account had. Deactivation does not clear `IsAdmin`, so a deactivated administrator regains admin access. Bound: accounts disabled with "Prohibit Login" stay blocked; this defeats the `IsActive=false` deactivation only.

Are you affected?

Enter the version of the package you're using.

Affected packages

Go / code.gitea.io/gitea
Introduced in: 0 Fixed in: 1.27.0
Fix go get code.gitea.io/gitea@v1.27.0

References