Escape research found a scope bypass that made Gitea's restricted tokens full admins. It's now CVE-2026-117635.

A Gitea access token restricted to public resources reached the whole admin API, up to creating a new site administrator. CVE-2026-117635, fixed in v28.3.0.

Escape research found a scope bypass that made Gitea's restricted tokens full admins. It's now CVE-2026-117635.

Gitea is a self-hosted Git forge, the kind of service a company runs in-house instead of putting its repositories on GitHub, and it hands out API tokens the way GitHub does, with scopes that narrow what each token may reach.

When you tick "public only" on a Gitea access token, you rightly assume the thing that will carry it - a CI job, a status dashboard, or a mirror script - will hav eno business reading a private repository. If one of those tokens ends up in a build log, it exposes nothing an anonymous visitor couldn't already see.

Gitea enforces this. Point a public-only token at a private organization and it is refused, with an error that names the reason: token scope is limited to public orgs. The restriction is real, recent, and over 1.26.2 and 1.27.0 Gitea extended it to route group after route group.

Then you ask the admin API for the same organization, with the same token, and Gitea returns it. From there, the same token can create a new site administrator.

⚡ tl;dr

  • Gitea's public only token scope blocks private organizations on the normal API roputes but the admin API never checked it.
  • A public-only token with 'write:admin', issued from a site-admin account, could list ever private organization and every user's email, create a user, promote it to siute admin, and mint Actions runner registration tokens.
  • It's fixed in v28.3.0 as CVE-2026-117635. Revoking a leaked token doesn't remove accounts it created, so check your admin list after upgrading.
  • The takeaway for any API with scoped tokens: when the scope is checked route group by route group, look for the groups that don't call the check, and for groups that call a check with nothing to restrict. Gitea's fix attaches the scope to the authenticated user instead, so no route group has to remember it.

Where we looked

Gitea had already answered this question three times. CVE-2026-24791 brought the public-only restriction to the /api/v1/user routes in 1.26.2, rated High at 8.1. CVE-2026-58429 and GHSA-h56g-4qw7-2mxg brought it to the organization and team endpoints in 1.27.0.

When a restriction is added one route group at a time, past advisories tell you which groups are covered. So the next bug is likely in a group that isn't on that list yet, and the admin group wasn't.

Why it happened: the restriction is a question each route group has to ask

Gitea stores the scope decision on the request and leaves enforcement downstream. tokenRequiresScopes() does not reject anything; it sets a flag:

// routers/api/v1/api.go, the scope middleware
ctx.PublicOnly = publicOnly

Two middlewares read that flag, and they do different things. checkTokenPublicOnly() narrows: it looks at the scope category the route needs, decides what the public subset of that category is, and refuses only when the target object falls outside it. rejectPublicOnly() refuses outright.

Having both is the right design, because the correct answer genuinely differs by group. A repository route has a meaningful public subset, so narrowing is what you want. A route that manages blocked users does not, so rejection is what you want.

The admin group is the case where only one of the two answers works, and it is the harder one to arrive at by analogy. Here is the group in v28.2.0:

// routers/api/v1/api.go:1924
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryAdmin), reqToken(), reqSiteAdmin())

The narrowing middleware would not have helped. Its switch has cases for Repository, Issue, Organization, User, ActivityPub, Notification and Package, and no case for Admin, because there is no public subset of the admin API to narrow to. Attaching it would have compiled, passed review, and changed nothing. The only answer that works for this group is the other function.

At the handler level the same thing shows up as an unconditional visibility list. admin.GetAllOrgs asks the database for everything:

// routers/api/v1/admin/org.go:104-110
Visible: []api.VisibleType{api.VisibleTypePublic, api.VisibleTypeLimited, api.VisibleTypePrivate},

Two requests, one private organization

A token with public-only, write:admin, read:organization, read:user, issued from a site-admin account:

# the restriction holds on the organisation route
$ curl -s -H "Authorization: token $T" \
    http://gitea.lab/api/v1/orgs/secretorg
{"message":"token scope is limited to public orgs"}

# the same organisation, through the admin API
$ curl -s -H "Authorization: token $T" \
    http://gitea.lab/api/v1/admin/orgs
[{"name":"secretorg","visibility":"private", ...}]

The user routes behave the same way. GET /api/v1/users/{private-user} answers token scope is limited to public users, and GET /api/v1/admin/users returns every account on the instance with its email address.

What the same token does next

Reads are the smaller half. The admin group is a write surface, and the restriction was absent from all of it:

$ curl -s -X POST -H "Authorization: token $T" \
    -d '{"username":"newadmin","visibility":"private"}' \
    http://gitea.lab/api/v1/admin/users/victim/orgs
# 201 Created

The token creates a private organisation, and is then refused when it tries to read the organisation it just created. That pair of responses is the clearest statement of the shape: the restriction is attached to a route, so it describes where you asked rather than what you touched.

The same token creates a user through POST /api/v1/admin/users, promotes it with PATCH /api/v1/admin/users/{u} and {"admin":true}, and that account then calls the admin API in its own right. It also mints an Actions runner registration token, which is a path to code execution on whatever hosts register as runners.

Why a scoped-down token matters anyway

Public-only exists so that a credential can be handed out. It is the scope you choose for the integration you do not fully control, the script a contractor runs, the token that lives in a CI variable you know gets printed sometimes. Its value is that leaking it is survivable.

For the window this was open, a token chosen for that reason was a full site-admin credential, and the account it could create outlives its revocation.

The fix

v28.3.0 carries the line the report asked for:

// routers/api/v1/api.go:1940
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryAdmin), reqToken(), reqSiteAdmin(), rejectPublicOnly())

That line arrived inside something larger. PR #39690, refactor: carry the API token on the doer, touches 96 files and takes 103 lines out of the route table. Its first sentence is the part that matters: the token a request authenticated with now lives on the doer, so permission and visibility checks apply its scope from the doer alone. The same change adds seven rejectPublicOnly() call sites, puts the restriction on organization ownership, site admin, repository and organisation creation, generation and transfer, and makes a public-only token answer 404 rather than 403 on hidden users, organizations and repositories, the way GitHub does.

The sequence is worth stating plainly. The refactor was opened on 8 October, four days before the report was accepted, and it replaced an earlier attempt at the same cleanup. The gap the report named is one of several it closes. Answering "this group has no answer yet" by removing the need for every group to have one is the more expensive fix and the right one, and it is the reason reading this project's patch history was worth the time.

What to do

  • Upgrade to v28.3.0 or later.
  • Review your site-admin accounts and remove any you don't recognise. Revoking a token doesn't remove accounts it created.
  • Review Actions runners and runner registration tokens for ones you didn't set up.
  • List public-only tokens issued from site-admin accounts. Rotate any that were stored where they could leak, such as CI variables or build logs.
  • If you keep access logs, look for /api/v1/admin requests made with tokens that were meant to be public-only.

Responsible disclosure timeline

2026-08-02. Reported through Gitea's private advisory process. Severity proposed at High, 8.1, on the vector Gitea had already used for the same defect on the /api/v1/user routes.

2026-10-12. Report accepted by the maintainers.

2026-10-19. Closed by PR #39690, refactor: carry the API token on the doer.

2026-11-01. Released in v28.3.0. Advisory published as GHSA-37xv-m4w7-j2fh, CVE-2026-117635 assigned, rated High at 8.1 on the proposed vector.

The advisory credits a second reporter who found the same gap independently.

Takeaway

A scope only means something where something stops and reads it. While that reading lived in the route table, the flag on the request described intent and the enforcement lived in as many places as there were groups. While the obvious thing to test might be whether the restriction worked, it turned out that what was interesting to explore was which surfaces had not yet been asked about the restriction.

This pattern generalizes past Gitea to every API that ships scoped tokens. There are two things worth noting when a restriction is enforced per route group: which group has no answer yet, and which groups have an answer that cannot work for them. The second is harder to spot, because a narrowing check on a surface with nothing to narrow still looks like a check in review. Both go away when the scope travels with the identity instead of the route.

How continuous pentesting prevents vulns like this

The method behind this finding isn't inherently complex, but doing it manually and often enough to catch the gaps before it's too late is where it becomes hard. Every token scope needs to be paired with every route group and every visibility level, and each release can change what that would give you.

Gitea's history itself showed how often the result changed. The public-only restriction reached the user routes in 1.26.2, the organization and team routes in 1.27.0 and the admin routes in v28.3.0. In an API like this where the scope is enforced per route group, a group added in-between pentests falls through that gap.

Continuous pentesting is built to find this pattern. Ingesting past pentest and bug bounty reports, it builds attack paths based on previous weaknesses such as this. And it catches them as they're released, testing what each release changed, given to you with proof of exploitability.

Find out more about how Escape Continuous AI Pentesting could protect your attack surface from simple vulnerabilities falling through the cracks of a pentest schedule.


Discover more of Escape's research 💡