CVEReports
Reports
CVEReports

Automated vulnerability intelligence platform. Comprehensive reports for high-severity CVEs generated by AI.

Product

  • Home
  • Reports
  • Sitemap
  • RSS Feed

Company

  • About
  • Privacy Policy
  • Terms of Service

© 2026 CVEReports. All rights reserved.

Powered by Google Gemini & CVE Feed

|
•

CVE-2025-68941
CVSS 4.9|EPSS 0.03%

Gitea's 'Public Only' API Tokens: More of a Guideline Than a Rule

Alon Barad
Alon Barad
Software Engineer•January 3, 2026•10 min read
No Known Exploit

Executive Summary (TL;DR)

A Gitea API token with a 'public_only' scope could be used to access private data. The application simply forgot to check the token's permissions on numerous API endpoints. Oops. Upgrade to version 1.22.3.

Gitea, a popular self-hosted Git service, suffered from an Incorrect Authorization vulnerability (CVE-2025-68941). The flaw stemmed from inconsistent enforcement of API token scopes, specifically the 'public_only' constraint. An attacker with a token explicitly limited to public resources could exploit this to gain unauthorized access to private repositories, issues, user details, and other sensitive data. The root cause was a decentralized and incomplete approach to authorization checks, which was later fixed by implementing a centralized middleware to consistently enforce the token's scope across all relevant API endpoints.

The 'Public Only' Token: A Suggestion, Not a Rule

In the world of automated development and CI/CD, API tokens are the keys to the kingdom. They allow scripts, services, and bots to interact with your source code management system, performing tasks from fetching code to managing issues. Gitea, like any sane SCM, provides a robust system for generating these tokens and, crucially, scoping them to limit their power. You can create a token that can only read repositories, or one that can only manage packages.

One of these scopes, public_only, is a particularly interesting security feature. It's designed to be the ultimate safety net. You can hand out a token with, say, read:issue and public_only scopes, and rest easy knowing it can only ever interact with the public-facing parts of your Gitea instance. It's the digital equivalent of a guest pass that only works in the lobby.

Or so we thought. It turns out that in Gitea versions before 1.22.3, this 'public_only' scope was treated less like a strict, unchangeable law of physics and more like a gentle suggestion that the application was free to ignore. And ignore it, it did. This is the story of how a security promise became a wide-open back door.

Schrödinger's Authorization Check

The fundamental flaw, classified as CWE-863 (Incorrect Authorization), wasn't some complex cryptographic failure or a memory corruption bug. It was much simpler, and frankly, much more common: the application just... forgot. It forgot to check the permissions slip. In some parts of the API, the public_only scope was respected, but in many others, it was completely overlooked.

This is a classic case of decentralized security logic gone wrong. Instead of a single, central bouncer checking every ID at the door, Gitea had scattered, inconsistent checks throughout its codebase. One API handler for listing repositories might remember to check the flag, while another for fetching issues from a specific repository would completely forget. This created a state of 'Schrödinger's Authorization'—the check both existed and didn't exist, depending on which endpoint you hit.

This kind of vulnerability is particularly insidious because it breaks a core assumption of the security model. Developers and administrators create these limited-scope tokens believing they are safe. They build automation around this belief, potentially embedding these 'harmless' tokens in less-secure environments. The vulnerability turns this perceived safety feature into a weapon, allowing an attacker to escalate a guest pass into a master key for your most private data.

A Tale of Two Contexts: The Smoking Gun

To understand where it all went wrong, we need to look at the code. The patch in Pull Request #32218 tells a very clear story of fixing a distributed mess by imposing centralized order.

Before the fix, the logic was fragmented. The system would set ad-hoc flags in the request context, like ApiTokenScopePublicRepoOnly and ApiTokenScopePublicOrgOnly. You can see this logic being ripped out in the diff:

- 		ctx.Data["ApiTokenScopePublicRepoOnly"] = false
- 		ctx.Data["ApiTokenScopePublicOrgOnly"] = false
...
- 		// this context is used by the middleware in the specific route
- 		ctx.Data["ApiTokenScopePublicRepoOnly"] = publicOnly && auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryRepository)
- 		ctx.Data["ApiTokenScopePublicOrgOnly"] = publicOnly && auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryOrganization)

This approach is fragile. Every developer adding a new API endpoint had to remember to check for these specific, magic strings in the ctx.Data map. If they forgot, or if the resource type didn't fit neatly into 'Repo' or 'Org', the check was bypassed. It's like leaving a sticky note on the fridge that says 'check for burglars' and hoping every family member sees it.

The patch introduces a far more elegant solution. First, it adds a PublicOnly boolean field directly to the APIContext struct. This makes the security context a first-class citizen, not an afterthought in a generic map.

+ PublicOnly bool // Whether the request is for a public endpoint

Second, and most importantly, it introduces a new, centralized middleware function: checkTokenPublicOnly(). This function acts as the single, authoritative bouncer. It checks ctx.PublicOnly and then, based on the type of resource being requested, verifies its visibility. If a public_only token is used to access a private resource, it's immediately shut down with a 403 Forbidden. No ifs, ands, or buts.

func checkTokenPublicOnly() func(ctx *context.APIContext) {
	return func(ctx *context.APIContext) {
		if !ctx.PublicOnly {
			return
		}
 
		// ... switch statement to check visibility ...
		switch {
		case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryRepository):
			if ctx.Repo.Repository != nil && ctx.Repo.Repository.IsPrivate {
				ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public repos")
				return
			}
        // ... more cases for issues, orgs, users, etc. ...
		}
	}
}

Finally, this new middleware is applied religiously across dozens of API routes, from fetching issues to listing user organizations. This ensures that the check is no longer optional or easy to forget. It's now an integral part of the request lifecycle for any sensitive endpoint.

Walking Through the Unlocked Back Door

So, how would an attacker actually exploit this? Let's paint a picture. Meet Alice, a low-privileged user, maybe a contractor or a bot account. She has an account on a company's Gitea instance, but only has access to public repositories. She wants to peek at the super-secret 'Project Chimera' private repository.

First, Alice generates an API token for her account. She's not an admin, so she can't grant herself god-mode scopes. She creates a token with read:issue and, crucially, the public_only scope. On the surface, this token looks harmless.

Next, she identifies a vulnerable endpoint. Before the patch, the endpoint to list issues for a repository, /api/v1/repos/{owner}/{repo}/issues, was one such endpoint. It correctly checked if the user had read:issue permissions but completely forgot to cross-reference that with the public_only flag on the token.

Armed with her 'harmless' token and the target endpoint, she crafts her attack. It's as simple as a curl command:

# Alice's seemingly innocuous, public-only token
TOKEN="gtp_aTotallyHarmlessPublicToken..."
 
# The target: a private repository she should NOT be able to access
TARGET_REPO="megacorp/project-chimera"
 
# The exploit request
curl -H "Authorization: token $TOKEN" \
     "https://gitea.example.com/api/v1/repos/$TARGET_REPO/issues"

To her delight (and the CISO's horror), the API responds not with a 403 Forbidden error, but with a 200 OK and a JSON array full of every issue in the private repository. She now has access to bug reports, feature discussions, and potentially sensitive information disclosed in the issues of Project Chimera. The lobby guest pass just opened the door to the CEO's office.

Here is a simple visualization of the attack flow:

Your Secrets Aren't Secret Anymore

The impact of this vulnerability is far more severe than the 'Medium' CVSS score of 4.9 might suggest. That score heavily weighs the prerequisites: an attacker needs an account (PR:L) and the attack complexity is considered high (AC:H), likely because they need to know the name of a private resource to target it. But in many real-world scenarios, these hurdles are trivial.

Once exploited, an attacker can exfiltrate the intellectual property sitting in your private repositories. This isn't just source code; it's also the entire history of its development contained within issues, pull requests, and comments. Think about all the secrets that get casually dropped in issue trackers: internal hostnames, credentials for staging environments, sensitive customer data for bug reproduction, and architectural diagrams.

This kind of information leak is a goldmine for a determined attacker. It can be used to pivot deeper into your network, launch more sophisticated social engineering attacks, or simply be sold on the dark web. In the context of supply chain security, gaining access to private source code could allow an adversary to study it for other vulnerabilities or even prepare a malicious contribution that looks legitimate because it references internal issue numbers and discussions.

Applying the Centralized Lock

Fortunately, the fix is straightforward: upgrade your Gitea instance to version 1.22.3 or later. This version contains the centralized authorization middleware that slams the door shut on this vulnerability. There are no complex workarounds or partial mitigations; patching is the only way to be truly safe.

Beyond just patching, this vulnerability serves as a critical lesson in software security architecture. Authorization logic should never be left to the discretion of individual developers implementing features. It must be centralized, non-optional, and applied consistently across the entire application. The move from scattered ctx.Data checks to a mandatory middleware is a textbook example of doing this correctly.

As a preventative measure, organizations should conduct a thorough audit of all existing API tokens. Revoke any that are unused and ensure that active tokens adhere to the principle of least privilege. While you're at it, review who has the ability to create tokens in the first place. Every token is a potential key, and you want to be very careful about who gets a copy.

Hunting for Ghosts in the Machine

If I were tasked with finding a bypass for this patch, I wouldn't waste time trying to break the new checkTokenPublicOnly middleware itself. It looks solid. Instead, I'd hunt for places where it's not being used.

My first step would be to meticulously audit every single API route defined in Gitea's source code, cross-referencing it against the changes in PR #32218. The developers were thorough, but in a codebase this large, it's easy to miss one obscure endpoint. I'd be particularly interested in any new API routes added in versions after 1.22.3. Did the new developers remember to add the checkTokenPublicOnly middleware? Forgetting to do so would reintroduce the bug for that specific endpoint.

Second, I'd dig into the // FIXME comments left in the code. Comments like // FIXME: we need org in context are breadcrumbs. They suggest that the context object required for the security check might not always be available. If I could find a way to make a request where ctx.Org is nil when checkTokenPublicOnly runs, the check ctx.Org.Organization.Visibility != api.VisibleTypePublic would likely panic or be bypassed, potentially letting me through.

Finally, I'd investigate indirect information disclosure. The patch is great at stopping direct access to a private resource. But what if a public resource's API response includes details about a related private resource? For example, does the API for a public user still list the names of the private organizations they belong to? An attacker could use these small leaks to map out the internal landscape of an organization, even if they can't access the resources directly. The game of cat and mouse never truly ends.

Official Patches

GiteaOfficial Pull Request containing the fix
GiteaOfficial release announcement for version 1.22.3

Fix Analysis (1)

Technical Appendix

CVSS Score
4.9/ 10
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:L/I:L/A:N
EPSS Probability
0.03%
Top 100% most exploited

Affected Systems

Gitea

Affected Versions Detail

ProductAffected VersionsFixed Version
Gitea
Gitea
< 1.22.31.22.3
AttributeDetail
CWE IDCWE-863
CWE NameIncorrect Authorization
Attack VectorNetwork
Attack ComplexityHigh
Privileges RequiredLow
CVSS v3.1 Score4.9 (Medium)
EPSS Score0.03% (0.00028)
Exploit StatusNo public exploits known
CISA KEVNo

MITRE ATT&CK Mapping

MITRE ATT&CK Mapping

T1190Exploit Public-Facing Application
Initial Access
T1078Valid Accounts
Initial Access
CWE-863
Incorrect Authorization

The software does not perform an authorization check when an actor attempts to access a resource or perform an action.

Vulnerability Timeline

Vulnerability Timeline

Gitea version 1.22.3, containing the fix, is released.
2024-10-09
CVE-2025-68941 is published.
2025-12-26

References & Sources

  • [1]NVD - CVE-2025-68941
  • [2]Fixing Pull Request #32218
  • [3]Gitea 1.22.3 Release Blog
  • [4]GitHub Advisory Database

Subscribe to updates

Get the latest CVE analysis reports delivered to your inbox.

Attack Flow Diagram

Press enter or space to select a node. You can then use the arrow keys to move the node around. Press delete to remove it and escape to cancel.
Press enter or space to select an edge. You can then press delete to remove it or escape to cancel.