{"schema_version":"1.7.5","id":"GHSA-vrhc-jjfc-m3m3","published":"2026-07-21T21:02:10Z","modified":"2026-07-27T17:11:22.465786398Z","aliases":["CVE-2026-55987","GO-2026-6078"],"summary":"Gitea: OAuth2 sign-in reactivates an administrator-deactivated account on auth sources without refresh tokens (incomplete fix of #38009)","details":"## Description\n\nGitea'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.)\n\nNo special privileges are required beyond being the deactivated user and being able to sign in through the source.\n\n### Root Cause\n\n`routers/web/auth/oauth.go` (the `handleOAuth2SignIn` reactivation gate):\n\n```go\nif !u.IsActive {\n    extLogin, hasExt, err := user_model.GetExternalLogin(ctx, authSource.ID, gothUser.UserID)\n    if err != nil { ctx.ServerError(\"GetExternalLogin\", err); return }\n    isDisabledByAutoSync := hasExt && extLogin.RefreshToken == \"\"   // wrong signal\n    if isDisabledByAutoSync {\n        opts.IsActive = optional.Some(true)                          // reactivates the account\n    }\n}\n```\n\nThe assumption that `RefreshToken == \"\"` is produced only by the auto-sync cron is false:\n\n- 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.\n- 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.\n- 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 `\"\"`.\n\nSo 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.\n\n### Proof of Concept\n\nSetup:\n- A Gitea instance with a GitHub authentication source (Admin Panel -> Authentication Sources -> OAuth2 -> GitHub), or any OAuth2/OIDC source configured without `offline_access`.\n- 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`).\n\nSteps:\n1. As an administrator, open Admin Panel -> Users -> V and uncheck \"Activated\" (`is_active=false`). Confirm V's requests now bounce to the activation page.\n2. As V, sign in again via \"Sign in with GitHub\" and complete the provider flow.\n3. V lands in the application with a working session. `SELECT is_active FROM \"user\" WHERE lower_name='v';` now returns `true`.\n\nExpected (intended by #38009): V stays `is_active=false` and is routed to the activation page.\nActual: V is `is_active=true` with a full session — the administrator's deactivation is undone.\n\n```\n- GitHub user, ADMIN deactivated                 refreshToken=\"\"    -> REACTIVATED + session granted   <<< admin action undone\n- OIDC user w/ refresh token, ADMIN deactivated  refreshToken=\"...\" -> stays disabled  (control)\n- OIDC user, AUTO-SYNC cron disabled             refreshToken=\"\"    -> REACTIVATED (intended)\nRESULT: BYPASS CONFIRMED.\n```\n\nGitea'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.\n\n### Impact\n\nAny 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.","affected":[{"package":{"name":"code.gitea.io/gitea","ecosystem":"Go","purl":"pkg:golang/code.gitea.io/gitea"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"1.27.0"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/07/GHSA-vrhc-jjfc-m3m3/GHSA-vrhc-jjfc-m3m3.json"}}],"references":[{"type":"WEB","url":"https://github.com/go-gitea/gitea/security/advisories/GHSA-vrhc-jjfc-m3m3"},{"type":"PACKAGE","url":"https://github.com/go-gitea/gitea"},{"type":"WEB","url":"https://github.com/go-gitea/gitea/releases/tag/v1.27.0"}],"database_specific":{"cwe_ids":["CWE-863"],"github_reviewed":true,"github_reviewed_at":"2026-07-21T21:02:10Z","nvd_published_at":null,"severity":"HIGH"},"severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N"}]}