VYPR
Medium severity4.3GHSA Advisory· Published Jun 16, 2026· Updated Jun 16, 2026

Gitea: Incomplete CVE-2025-68941 fix: /user/orgs missing checkTokenPublicOnly + switch-case logic flaw

CVE-2026-25714

Description

Public-only scoped tokens in Gitea can enumerate private organizations due to two flaws in scope enforcement on the /user/orgs endpoint.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Public-only scoped tokens in Gitea can enumerate private organizations due to two flaws in scope enforcement on the /user/orgs endpoint.

Vulnerability

Two related issues in Gitea's token public-only scope enforcement, introduced by PR #32204 (CVE-2025-68941 fix), allow a public-only scoped API token to access private organization data. Issue 1: The /user/orgs route at routers/api/v1/api.go line 1599 is missing a checkTokenPublicOnly() call, unlike the adjacent route at line 1603. Issue 2: The checkTokenPublicOnly() function uses a Go switch statement (lines 253-295) that evaluates only the first matching case. For routes with categories [User, Organization], the Organization case matches first but passes because ctx.Org.Organization is nil on user routes, and ctx.ContextUser.IsOrganization() is false, causing the User visibility check to be skipped entirely. Affected version: current main branch, commit 2c2d7e6 (April 3, 2026) [1][2].

Exploitation

To exploit, an attacker must have a Gitea account and create a token with public-only scope (Settings > Applications > check "public only"). With that token, they call curl -H "Authorization: token <PUBLIC_ONLY_TOKEN>" https://gitea.example.com/api/v1/user/orgs. The response includes private and limited-visibility organizations that the token owner belongs to, violating the token's declared scope constraints [1][2].

Impact

A public-only scoped token can enumerate private organizations the token owner belongs to. This allows an attacker to gain information about private organizations that should not be accessible with a public-only token, breaching the confidentiality of organization membership and visibility settings [1][2].

Mitigation

As of the advisory publication (June 16, 2026), the suggested fix is to add checkTokenPublicOnly() to the /user/orgs route at line 1599 and replace the switch with a loop over all categories so the User visibility check is not skipped. No patched version has been released; users should apply the fix manually or wait for an official release. The vulnerability is not listed in CISA's Known Exploited Vulnerabilities (KEV) catalog [1][2].

AI Insight generated on Jun 16, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

1

Patches

1
d6d3c96e6555

Fix bug when a token is given public only (#32204)

https://github.com/go-gitea/giteaLunny XiaoOct 8, 2024via body-scan-shorthand
11 files changed · +176 55
  • models/user/user.go+4 0 modified
    @@ -408,6 +408,10 @@ func (u *User) IsIndividual() bool {
     	return u.Type == UserTypeIndividual
     }
     
    +func (u *User) IsUser() bool {
    +	return u.Type == UserTypeIndividual || u.Type == UserTypeBot
    +}
    +
     // IsBot returns whether or not the user is of type bot
     func (u *User) IsBot() bool {
     	return u.Type == UserTypeBot
    
  • routers/api/packages/api.go+14 0 modified
    @@ -63,6 +63,20 @@ func reqPackageAccess(accessMode perm.AccessMode) func(ctx *context.Context) {
     					ctx.Error(http.StatusUnauthorized, "reqPackageAccess", "user should have specific permission or be a site admin")
     					return
     				}
    +
    +				// check if scope only applies to public resources
    +				publicOnly, err := scope.PublicOnly()
    +				if err != nil {
    +					ctx.Error(http.StatusForbidden, "tokenRequiresScope", "parsing public resource scope failed: "+err.Error())
    +					return
    +				}
    +
    +				if publicOnly {
    +					if ctx.Package != nil && ctx.Package.Owner.Visibility.IsPrivate() {
    +						ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public packages")
    +						return
    +					}
    +				}
     			}
     		}
     
    
  • routers/api/v1/api.go+83 48 modified
    @@ -235,6 +235,62 @@ func reqPackageAccess(accessMode perm.AccessMode) func(ctx *context.APIContext)
     	}
     }
     
    +func checkTokenPublicOnly() func(ctx *context.APIContext) {
    +	return func(ctx *context.APIContext) {
    +		if !ctx.PublicOnly {
    +			return
    +		}
    +
    +		requiredScopeCategories, ok := ctx.Data["requiredScopeCategories"].([]auth_model.AccessTokenScopeCategory)
    +		if !ok || len(requiredScopeCategories) == 0 {
    +			return
    +		}
    +
    +		// public Only permission check
    +		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
    +			}
    +		case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryIssue):
    +			if ctx.Repo.Repository != nil && ctx.Repo.Repository.IsPrivate {
    +				ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public issues")
    +				return
    +			}
    +		case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryOrganization):
    +			if ctx.Org.Organization != nil && ctx.Org.Organization.Visibility != api.VisibleTypePublic {
    +				ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public orgs")
    +				return
    +			}
    +			if ctx.ContextUser != nil && ctx.ContextUser.IsOrganization() && ctx.ContextUser.Visibility != api.VisibleTypePublic {
    +				ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public orgs")
    +				return
    +			}
    +		case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryUser):
    +			if ctx.ContextUser != nil && ctx.ContextUser.IsUser() && ctx.ContextUser.Visibility != api.VisibleTypePublic {
    +				ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public users")
    +				return
    +			}
    +		case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryActivityPub):
    +			if ctx.ContextUser != nil && ctx.ContextUser.IsUser() && ctx.ContextUser.Visibility != api.VisibleTypePublic {
    +				ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public activitypub")
    +				return
    +			}
    +		case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryNotification):
    +			if ctx.Repo.Repository != nil && ctx.Repo.Repository.IsPrivate {
    +				ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public notifications")
    +				return
    +			}
    +		case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryPackage):
    +			if ctx.Package != nil && ctx.Package.Owner.Visibility.IsPrivate() {
    +				ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public packages")
    +				return
    +			}
    +		}
    +	}
    +}
    +
     // if a token is being used for auth, we check that it contains the required scope
     // if a token is not being used, reqToken will enforce other sign in methods
     func tokenRequiresScopes(requiredScopeCategories ...auth_model.AccessTokenScopeCategory) func(ctx *context.APIContext) {
    @@ -250,9 +306,6 @@ func tokenRequiresScopes(requiredScopeCategories ...auth_model.AccessTokenScopeC
     			return
     		}
     
    -		ctx.Data["ApiTokenScopePublicRepoOnly"] = false
    -		ctx.Data["ApiTokenScopePublicOrgOnly"] = false
    -
     		// use the http method to determine the access level
     		requiredScopeLevel := auth_model.Read
     		if ctx.Req.Method == "POST" || ctx.Req.Method == "PUT" || ctx.Req.Method == "PATCH" || ctx.Req.Method == "DELETE" {
    @@ -261,29 +314,28 @@ func tokenRequiresScopes(requiredScopeCategories ...auth_model.AccessTokenScopeC
     
     		// get the required scope for the given access level and category
     		requiredScopes := auth_model.GetRequiredScopes(requiredScopeLevel, requiredScopeCategories...)
    -
    -		// check if scope only applies to public resources
    -		publicOnly, err := scope.PublicOnly()
    +		allow, err := scope.HasScope(requiredScopes...)
     		if err != nil {
    -			ctx.Error(http.StatusForbidden, "tokenRequiresScope", "parsing public resource scope failed: "+err.Error())
    +			ctx.Error(http.StatusForbidden, "tokenRequiresScope", "checking scope failed: "+err.Error())
     			return
     		}
     
    -		// 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)
    -
    -		allow, err := scope.HasScope(requiredScopes...)
    -		if err != nil {
    -			ctx.Error(http.StatusForbidden, "tokenRequiresScope", "checking scope failed: "+err.Error())
    +		if !allow {
    +			ctx.Error(http.StatusForbidden, "tokenRequiresScope", fmt.Sprintf("token does not have at least one of required scope(s): %v", requiredScopes))
     			return
     		}
     
    -		if allow {
    +		ctx.Data["requiredScopeCategories"] = requiredScopeCategories
    +
    +		// check if scope only applies to public resources
    +		publicOnly, err := scope.PublicOnly()
    +		if err != nil {
    +			ctx.Error(http.StatusForbidden, "tokenRequiresScope", "parsing public resource scope failed: "+err.Error())
     			return
     		}
     
    -		ctx.Error(http.StatusForbidden, "tokenRequiresScope", fmt.Sprintf("token does not have at least one of required scope(s): %v", requiredScopes))
    +		// assign to true so that those searching should only filter public repositories/users/organizations
    +		ctx.PublicOnly = publicOnly
     	}
     }
     
    @@ -295,25 +347,6 @@ func reqToken() func(ctx *context.APIContext) {
     			return
     		}
     
    -		if true == ctx.Data["IsApiToken"] {
    -			publicRepo, pubRepoExists := ctx.Data["ApiTokenScopePublicRepoOnly"]
    -			publicOrg, pubOrgExists := ctx.Data["ApiTokenScopePublicOrgOnly"]
    -
    -			if pubRepoExists && publicRepo.(bool) &&
    -				ctx.Repo.Repository != nil && ctx.Repo.Repository.IsPrivate {
    -				ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public repos")
    -				return
    -			}
    -
    -			if pubOrgExists && publicOrg.(bool) &&
    -				ctx.Org.Organization != nil && ctx.Org.Organization.Visibility != api.VisibleTypePublic {
    -				ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public orgs")
    -				return
    -			}
    -
    -			return
    -		}
    -
     		if ctx.IsSigned {
     			return
     		}
    @@ -879,11 +912,11 @@ func Routes() *web.Router {
     				m.Group("/user/{username}", func() {
     					m.Get("", activitypub.Person)
     					m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox)
    -				}, context.UserAssignmentAPI())
    +				}, context.UserAssignmentAPI(), checkTokenPublicOnly())
     				m.Group("/user-id/{user-id}", func() {
     					m.Get("", activitypub.Person)
     					m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox)
    -				}, context.UserIDAssignmentAPI())
    +				}, context.UserIDAssignmentAPI(), checkTokenPublicOnly())
     			}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryActivityPub))
     		}
     
    @@ -939,7 +972,7 @@ func Routes() *web.Router {
     				}, reqSelfOrAdmin(), reqBasicOrRevProxyAuth())
     
     				m.Get("/activities/feeds", user.ListUserActivityFeeds)
    -			}, context.UserAssignmentAPI(), individualPermsChecker)
    +			}, context.UserAssignmentAPI(), checkTokenPublicOnly(), individualPermsChecker)
     		}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser))
     
     		// Users (requires user scope)
    @@ -957,7 +990,7 @@ func Routes() *web.Router {
     				m.Get("/starred", user.GetStarredRepos)
     
     				m.Get("/subscriptions", user.GetWatchedRepos)
    -			}, context.UserAssignmentAPI())
    +			}, context.UserAssignmentAPI(), checkTokenPublicOnly())
     		}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken())
     
     		// Users (requires user scope)
    @@ -1044,7 +1077,7 @@ func Routes() *web.Router {
     					m.Get("", user.IsStarring)
     					m.Put("", user.Star)
     					m.Delete("", user.Unstar)
    -				}, repoAssignment())
    +				}, repoAssignment(), checkTokenPublicOnly())
     			}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))
     			m.Get("/times", repo.ListMyTrackedTimes)
     			m.Get("/stopwatches", repo.GetStopwatches)
    @@ -1069,18 +1102,20 @@ func Routes() *web.Router {
     					m.Get("", user.CheckUserBlock)
     					m.Put("", user.BlockUser)
     					m.Delete("", user.UnblockUser)
    -				}, context.UserAssignmentAPI())
    +				}, context.UserAssignmentAPI(), checkTokenPublicOnly())
     			})
     		}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken())
     
     		// Repositories (requires repo scope, org scope)
     		m.Post("/org/{org}/repos",
    +			// FIXME: we need org in context
     			tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization, auth_model.AccessTokenScopeCategoryRepository),
     			reqToken(),
     			bind(api.CreateRepoOption{}),
     			repo.CreateOrgRepoDeprecated)
     
     		// requires repo scope
    +		// FIXME: Don't expose repository id outside of the system
     		m.Combo("/repositories/{id}", reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)).Get(repo.GetByID)
     
     		// Repos (requires repo scope)
    @@ -1334,7 +1369,7 @@ func Routes() *web.Router {
     					m.Post("", bind(api.UpdateRepoAvatarOption{}), repo.UpdateAvatar)
     					m.Delete("", repo.DeleteAvatar)
     				}, reqAdmin(), reqToken())
    -			}, repoAssignment())
    +			}, repoAssignment(), checkTokenPublicOnly())
     		}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))
     
     		// Notifications (requires notifications scope)
    @@ -1343,7 +1378,7 @@ func Routes() *web.Router {
     				m.Combo("/notifications", reqToken()).
     					Get(notify.ListRepoNotifications).
     					Put(notify.ReadRepoNotifications)
    -			}, repoAssignment())
    +			}, repoAssignment(), checkTokenPublicOnly())
     		}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryNotification))
     
     		// Issue (requires issue scope)
    @@ -1457,7 +1492,7 @@ func Routes() *web.Router {
     						Patch(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), bind(api.EditMilestoneOption{}), repo.EditMilestone).
     						Delete(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), repo.DeleteMilestone)
     				})
    -			}, repoAssignment())
    +			}, repoAssignment(), checkTokenPublicOnly())
     		}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue))
     
     		// NOTE: these are Gitea package management API - see packages.CommonRoutes and packages.DockerContainerRoutes for endpoints that implement package manager APIs
    @@ -1468,14 +1503,14 @@ func Routes() *web.Router {
     				m.Get("/files", reqToken(), packages.ListPackageFiles)
     			})
     			m.Get("/", reqToken(), packages.ListPackages)
    -		}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryPackage), context.UserAssignmentAPI(), context.PackageAssignmentAPI(), reqPackageAccess(perm.AccessModeRead))
    +		}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryPackage), context.UserAssignmentAPI(), context.PackageAssignmentAPI(), reqPackageAccess(perm.AccessModeRead), checkTokenPublicOnly())
     
     		// Organizations
     		m.Get("/user/orgs", reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), org.ListMyOrgs)
     		m.Group("/users/{username}/orgs", func() {
     			m.Get("", reqToken(), org.ListUserOrgs)
     			m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions)
    -		}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), context.UserAssignmentAPI())
    +		}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), context.UserAssignmentAPI(), checkTokenPublicOnly())
     		m.Post("/orgs", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), reqToken(), bind(api.CreateOrgOption{}), org.Create)
     		m.Get("/orgs", org.GetAll, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization))
     		m.Group("/orgs/{org}", func() {
    @@ -1533,7 +1568,7 @@ func Routes() *web.Router {
     					m.Delete("", org.UnblockUser)
     				})
     			}, reqToken(), reqOrgOwnership())
    -		}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true))
    +		}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true), checkTokenPublicOnly())
     		m.Group("/teams/{teamid}", func() {
     			m.Combo("").Get(reqToken(), org.GetTeam).
     				Patch(reqToken(), reqOrgOwnership(), bind(api.EditTeamOption{}), org.EditTeam).
    @@ -1553,7 +1588,7 @@ func Routes() *web.Router {
     					Get(reqToken(), org.GetTeamRepo)
     			})
     			m.Get("/activities/feeds", org.ListTeamActivityFeeds)
    -		}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), reqTeamMembership())
    +		}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), reqTeamMembership(), checkTokenPublicOnly())
     
     		m.Group("/admin", func() {
     			m.Group("/cron", func() {
    
  • routers/api/v1/org/org.go+1 1 modified
    @@ -191,7 +191,7 @@ func GetAll(ctx *context.APIContext) {
     	//     "$ref": "#/responses/OrganizationList"
     
     	vMode := []api.VisibleType{api.VisibleTypePublic}
    -	if ctx.IsSigned {
    +	if ctx.IsSigned && !ctx.PublicOnly {
     		vMode = append(vMode, api.VisibleTypeLimited)
     		if ctx.Doer.IsAdmin {
     			vMode = append(vMode, api.VisibleTypePrivate)
    
  • routers/api/v1/repo/issue.go+1 1 modified
    @@ -149,7 +149,7 @@ func SearchIssues(ctx *context.APIContext) {
     			Actor:   ctx.Doer,
     		}
     		if ctx.IsSigned {
    -			opts.Private = true
    +			opts.Private = !ctx.PublicOnly
     			opts.AllLimited = true
     		}
     		if ctx.FormString("owner") != "" {
    
  • routers/api/v1/repo/repo.go+6 1 modified
    @@ -129,6 +129,11 @@ func Search(ctx *context.APIContext) {
     	//   "422":
     	//     "$ref": "#/responses/validationError"
     
    +	private := ctx.IsSigned && (ctx.FormString("private") == "" || ctx.FormBool("private"))
    +	if ctx.PublicOnly {
    +		private = false
    +	}
    +
     	opts := &repo_model.SearchRepoOptions{
     		ListOptions:        utils.GetListOptions(ctx),
     		Actor:              ctx.Doer,
    @@ -138,7 +143,7 @@ func Search(ctx *context.APIContext) {
     		TeamID:             ctx.FormInt64("team_id"),
     		TopicOnly:          ctx.FormBool("topic"),
     		Collaborate:        optional.None[bool](),
    -		Private:            ctx.IsSigned && (ctx.FormString("private") == "" || ctx.FormBool("private")),
    +		Private:            private,
     		Template:           optional.None[bool](),
     		StarredByID:        ctx.FormInt64("starredBy"),
     		IncludeDescription: ctx.FormBool("includeDesc"),
    
  • routers/api/v1/user/user.go+6 0 modified
    @@ -9,6 +9,7 @@ import (
     
     	activities_model "code.gitea.io/gitea/models/activities"
     	user_model "code.gitea.io/gitea/models/user"
    +	"code.gitea.io/gitea/modules/structs"
     	"code.gitea.io/gitea/routers/api/v1/utils"
     	"code.gitea.io/gitea/services/context"
     	"code.gitea.io/gitea/services/convert"
    @@ -67,12 +68,17 @@ func Search(ctx *context.APIContext) {
     		maxResults = 1
     		users = []*user_model.User{user_model.NewActionsUser()}
     	default:
    +		var visible []structs.VisibleType
    +		if ctx.PublicOnly {
    +			visible = []structs.VisibleType{structs.VisibleTypePublic}
    +		}
     		users, maxResults, err = user_model.SearchUsers(ctx, &user_model.SearchUserOptions{
     			Actor:         ctx.Doer,
     			Keyword:       ctx.FormTrim("q"),
     			UID:           uid,
     			Type:          user_model.UserTypeIndividual,
     			SearchByEmail: true,
    +			Visible:       visible,
     			ListOptions:   listOptions,
     		})
     		if err != nil {
    
  • services/context/api.go+4 3 modified
    @@ -35,9 +35,10 @@ type APIContext struct {
     
     	ContextUser *user_model.User // the user which is being visited, in most cases it differs from Doer
     
    -	Repo    *Repository
    -	Org     *APIOrganization
    -	Package *Package
    +	Repo       *Repository
    +	Org        *APIOrganization
    +	Package    *Package
    +	PublicOnly bool // Whether the request is for a public endpoint
     }
     
     func init() {
    
  • tests/integration/api_issue_test.go+34 0 modified
    @@ -75,6 +75,34 @@ func TestAPIListIssues(t *testing.T) {
     	}
     }
     
    +func TestAPIListIssuesPublicOnly(t *testing.T) {
    +	defer tests.PrepareTestEnv(t)()
    +
    +	repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
    +	owner1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo1.OwnerID})
    +
    +	session := loginUser(t, owner1.Name)
    +	token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue)
    +	link, _ := url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner1.Name, repo1.Name))
    +	link.RawQuery = url.Values{"state": {"all"}}.Encode()
    +	req := NewRequest(t, "GET", link.String()).AddTokenAuth(token)
    +	MakeRequest(t, req, http.StatusOK)
    +
    +	repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
    +	owner2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo2.OwnerID})
    +
    +	session = loginUser(t, owner2.Name)
    +	token = getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue)
    +	link, _ = url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner2.Name, repo2.Name))
    +	link.RawQuery = url.Values{"state": {"all"}}.Encode()
    +	req = NewRequest(t, "GET", link.String()).AddTokenAuth(token)
    +	MakeRequest(t, req, http.StatusOK)
    +
    +	publicOnlyToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue, auth_model.AccessTokenScopePublicOnly)
    +	req = NewRequest(t, "GET", link.String()).AddTokenAuth(publicOnlyToken)
    +	MakeRequest(t, req, http.StatusForbidden)
    +}
    +
     func TestAPICreateIssue(t *testing.T) {
     	defer tests.PrepareTestEnv(t)()
     	const body, title = "apiTestBody", "apiTestTitle"
    @@ -243,6 +271,12 @@ func TestAPISearchIssues(t *testing.T) {
     	DecodeJSON(t, resp, &apiIssues)
     	assert.Len(t, apiIssues, expectedIssueCount)
     
    +	publicOnlyToken := getUserToken(t, "user1", auth_model.AccessTokenScopeReadIssue, auth_model.AccessTokenScopePublicOnly)
    +	req = NewRequest(t, "GET", link.String()).AddTokenAuth(publicOnlyToken)
    +	resp = MakeRequest(t, req, http.StatusOK)
    +	DecodeJSON(t, resp, &apiIssues)
    +	assert.Len(t, apiIssues, 15) // 15 public issues
    +
     	since := "2000-01-01T00:50:01+00:00" // 946687801
     	before := time.Unix(999307200, 0).Format(time.RFC3339)
     	query.Add("since", since)
    
  • tests/integration/api_repo_branch_test.go+10 1 modified
    @@ -28,9 +28,13 @@ func TestAPIRepoBranchesPlain(t *testing.T) {
     		repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
     		user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
     		session := loginUser(t, user1.LowerName)
    -		token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
     
    +		// public only token should be forbidden
    +		publicOnlyToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopePublicOnly, auth_model.AccessTokenScopeWriteRepository)
     		link, _ := url.Parse(fmt.Sprintf("/api/v1/repos/org3/%s/branches", repo3.Name)) // a plain repo
    +		MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(publicOnlyToken), http.StatusForbidden)
    +
    +		token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
     		resp := MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK)
     		bs, err := io.ReadAll(resp.Body)
     		assert.NoError(t, err)
    @@ -42,13 +46,17 @@ func TestAPIRepoBranchesPlain(t *testing.T) {
     		assert.EqualValues(t, "master", branches[1].Name)
     
     		link2, _ := url.Parse(fmt.Sprintf("/api/v1/repos/org3/%s/branches/test_branch", repo3.Name))
    +		MakeRequest(t, NewRequest(t, "GET", link2.String()).AddTokenAuth(publicOnlyToken), http.StatusForbidden)
    +
     		resp = MakeRequest(t, NewRequest(t, "GET", link2.String()).AddTokenAuth(token), http.StatusOK)
     		bs, err = io.ReadAll(resp.Body)
     		assert.NoError(t, err)
     		var branch api.Branch
     		assert.NoError(t, json.Unmarshal(bs, &branch))
     		assert.EqualValues(t, "test_branch", branch.Name)
     
    +		MakeRequest(t, NewRequest(t, "POST", link.String()).AddTokenAuth(publicOnlyToken), http.StatusForbidden)
    +
     		req := NewRequest(t, "POST", link.String()).AddTokenAuth(token)
     		req.Header.Add("Content-Type", "application/json")
     		req.Body = io.NopCloser(bytes.NewBufferString(`{"new_branch_name":"test_branch2", "old_branch_name": "test_branch", "old_ref_name":"refs/heads/test_branch"}`))
    @@ -73,6 +81,7 @@ func TestAPIRepoBranchesPlain(t *testing.T) {
     
     		link3, _ := url.Parse(fmt.Sprintf("/api/v1/repos/org3/%s/branches/test_branch2", repo3.Name))
     		MakeRequest(t, NewRequest(t, "DELETE", link3.String()), http.StatusNotFound)
    +		MakeRequest(t, NewRequest(t, "DELETE", link3.String()).AddTokenAuth(publicOnlyToken), http.StatusForbidden)
     
     		MakeRequest(t, NewRequest(t, "DELETE", link3.String()).AddTokenAuth(token), http.StatusNoContent)
     		assert.NoError(t, err)
    
  • tests/integration/api_user_search_test.go+13 0 modified
    @@ -38,6 +38,19 @@ func TestAPIUserSearchLoggedIn(t *testing.T) {
     		assert.Contains(t, user.UserName, query)
     		assert.NotEmpty(t, user.Email)
     	}
    +
    +	publicToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopePublicOnly)
    +	req = NewRequestf(t, "GET", "/api/v1/users/search?q=%s", query).
    +		AddTokenAuth(publicToken)
    +	resp = MakeRequest(t, req, http.StatusOK)
    +	results = SearchResults{}
    +	DecodeJSON(t, resp, &results)
    +	assert.NotEmpty(t, results.Data)
    +	for _, user := range results.Data {
    +		assert.Contains(t, user.UserName, query)
    +		assert.NotEmpty(t, user.Email)
    +		assert.True(t, user.Visibility == "public")
    +	}
     }
     
     func TestAPIUserSearchNotLoggedIn(t *testing.T) {
    

Vulnerability mechanics

Root cause

"The `/user/orgs` route was missing the `checkTokenPublicOnly()` middleware, and the `checkTokenPublicOnly` function's Go switch statement only evaluated the first matching category, skipping the user visibility check."

Attack vector

An attacker who possesses a public-only scoped API token can enumerate private organizations the token owner belongs to. The `/user/orgs` route at `routers/api/v1/api.go` line 1599 was missing a `checkTokenPublicOnly()` middleware call, so no visibility filtering was applied to the response [ref_id=1]. Additionally, the `checkTokenPublicOnly` function used a Go `switch` statement that only executes the first matching case; for routes with categories `[User, Organization]`, the Organization case matches first but passes because `ctx.Org.Organization` is nil on user routes, and the User case is never reached, skipping the user visibility check entirely [ref_id=2].

What the fix does

The patch adds `checkTokenPublicOnly()` to the `/user/orgs` route and replaces the `switch` statement with a loop that iterates over all required scope categories, ensuring every category's visibility check is evaluated [patch_id=6217055]. It also introduces a `ctx.PublicOnly` boolean field on `APIContext` and propagates it to the repository search, user search, and organization listing endpoints so that private resources are filtered out when a public-only token is used. The old approach stored boolean flags in `ctx.Data` and only checked them in `reqToken()`, which was insufficient.

Preconditions

  • authAttacker must possess a valid API token with the 'public only' scope checked
  • authThe token must have at least one of the User or Organization scope categories
  • configThe target Gitea instance must have private or limited-visibility organizations that the token owner belongs to

Reproduction

1. Create a token with public-only scope (Settings > Applications > check "public only"). 2. Call: `curl -H "Authorization: token <PUBLIC_ONLY_TOKEN>" https://gitea.example.com/api/v1/user/orgs` 3. The response includes private and limited-visibility organizations, violating the token's declared scope constraints [ref_id=1].

Generated on Jun 17, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

2

News mentions

0

No linked articles in our index yet.