Post

CVE-2026-24791: public-only token bypass in Gitea self routes

CVE-2026-24791: public-only token bypass in Gitea self routes

I reported an authorization bypass in Gitea that was published as CVE-2026-24791 / GHSA-wrr5-99h5-gq57.

The issue affected public-only access tokens and OAuth grants. A token marked as public-only is supposed to be limited to public resources, but many authenticated self routes under /api/v1/user/... did not enforce that restriction. If the token also carried the route-required read or write scope category, it could still access or modify private account resources through those self routes.

Advisory

  • CVE: CVE-2026-24791
  • GitHub Advisory: GHSA-wrr5-99h5-gq57
  • Package: code.gitea.io/gitea
  • Ecosystem: Go module
  • Affected versions: >= 1.22.3, <= 1.26.1
  • Patched version: 1.26.2
  • Severity: High 8.1/10
  • CVSS: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N
  • CWE: CWE-863: Incorrect Authorization

Root Cause

Gitea’s API distinguishes between ordinary scoped tokens and tokens constrained by the public-only flag. The flag is meant to add an extra boundary: even if a token has a broad route category such as read:user or write:user, it should still be limited to public resources.

The vulnerable behavior came from inconsistent enforcement of that extra boundary.

The canonical user endpoint correctly rejected public-only tokens for private users:

1
GET /api/v1/users/{privateUser}

But the authenticated self-route group under /api/v1/user mostly required only the normal user scope and token authentication. Those routes could see that a token was public-only, but they did not consistently apply the public-only authorization check to the current authenticated user as the target resource owner.

That made the bug systemic. It was not one forgotten endpoint; it was a mismatch between how the canonical private-resource routes enforced public-only access and how the self routes handled the same token boundary.

Attack Shape

The attacker needs a token or OAuth grant that is valid for the victim account and marked as public-only. This is not an unauthenticated bug. The security failure is that the public-only restriction no longer means what it says once the request moves through affected /api/v1/user/... routes.

Representative affected surfaces included:

  • reading private self profile and account settings through /api/v1/user;
  • listing, adding, or deleting account email addresses through /api/v1/user/emails;
  • listing or adding SSH public keys through /api/v1/user/keys;
  • listing or creating OAuth2 applications, including returned client secrets;
  • creating, reading, updating, or deleting user-level Actions secrets and variables;
  • minting runner registration tokens and managing user-level runners;
  • creating or listing private repositories through /api/v1/user/repos when the token also had the required repository scope category;
  • leaking private metadata through subscriptions, tracked times, stopwatches, teams, hooks, workflow runs, and jobs.

The useful negative control was that the same public-only token could be rejected by the canonical private-user endpoint while still succeeding through the self-route family.

Impact

The impact is a scope-boundary bypass for integrations, OAuth grants, and leaked tokens that operators believed were restricted to public resources.

Practical abuse scenarios include:

  • a public-only token with write:user adding SSH keys or account email addresses;
  • a public-only OAuth grant creating OAuth applications and receiving client secrets;
  • a token modifying user-level Actions secrets, variables, or runners;
  • a token creating or listing private repositories when it also has the required repository scope;
  • a supposedly public-only integration learning private repository, workflow, team, timing, subscription, webhook, and email metadata.

The important point is that the attacker does not need to break token validation. The token is accepted as a valid token. The bug is that one of its most important restrictions is not enforced on a large route family.

Why This Was Easy To Miss

This was a residual gap after an older public-only token issue had already been fixed in a different route family.

That makes the failure mode subtle. A reviewer can test the obvious canonical endpoint, see 403 Forbidden, and conclude that public-only access is working. But self routes are a different shape: the resource owner is the authenticated user, not a username loaded from the route path. If the public-only check depends on that target user context, simply marking the request as public-only is not enough.

In other words, the route already knows who the caller is, but the authorization check still has to ask whether the caller’s token is allowed to reach private resources owned by that same caller.

Fix Direction

The fix needs to make public-only enforcement consistent across self routes.

Defensive principles:

  • treat ctx.Doer as the target user or resource owner when enforcing public-only access on /api/v1/user/... routes;
  • reject public-only tokens on credential, identity, OAuth application, repository creation, webhook, Actions, runner, and email-management mutations;
  • filter list routes so public-only tokens cannot return private repositories, private organization or team metadata, private workflow runs or jobs, private tracked time, private stopwatches, hidden subscriptions, or private webhooks;
  • add regression tests that compare affected self routes against equivalent canonical private-user or private-repository endpoints;
  • preserve current behavior for non-public-only tokens.

References

This post is licensed under CC BY 4.0 by the author.