Root cause
Authentication & access control
fix: Unify public-only token filtering in API queries and repo access checks (#37118)
AI-assisted change: ChatGPT/Codex
How AI contributed: Flawed AI-written codePublished Jul 21, 2026 in
go-gitea/giteaAll 2 findings in this repository
The AI-written change added TokenCanAccessRepo in services/context/api.go so that public-only tokens could not be used against private repositories, but the check denied access only when repo.IsPrivate was true. Repositories whose visibility is limited rather than public or private never satisfied that condition, so a public-only token for a limited-scope owner still reached content it was not entitled to see. The fix replaces the IsPrivate test with a !IsPublic check across the repository and packages API gates.
TokenCanAccessRepo returns false only when the caller presents a public-only token and repo.IsPrivate is true, so repositories with limited visibility never enter the denial branch. The fix widens the predicate to !IsPublic in the repository and packages API gates.
Root cause
fix: Unify public-only token filtering in API queries and repo access checks (#37118)
AI-assisted change: ChatGPT/Codex
Fix
fix: various security fixes (#38406)
Case facts
@@ -212,6 +212,11 @@ func repoAssignment() func(ctx *context.APIContext) { ctx.APIErrorNotFound() return }++ if !ctx.TokenCanAccessRepo(repo) {+ ctx.APIErrorNotFound()+ return+ } } } @@ -249,51 +254,66 @@ func checkTokenPublicOnly() func(ctx *context.APIContext) { return } - // public Only permission check- switch {- case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryRepository):- if ctx.Repo.Repository != nil && ctx.Repo.Repository.IsPrivate {- ctx.APIError(http.StatusForbidden, "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.APIError(http.StatusForbidden, "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.APIError(http.StatusForbidden, "token scope is limited to public orgs")- return- }- if ctx.ContextUser != nil && ctx.ContextUser.IsOrganization() && ctx.ContextUser.Visibility != api.VisibleTypePublic {- ctx.APIError(http.StatusForbidden, "token scope is limited to public orgs")- return- }- case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryUser):- if ctx.ContextUser != nil && ctx.ContextUser.IsTokenAccessAllowed() && ctx.ContextUser.Visibility != api.VisibleTypePublic {- ctx.APIError(http.StatusForbidden, "token scope is limited to public users")- return- }- case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryActivityPub):- if ctx.ContextUser != nil && ctx.ContextUser.IsTokenAccessAllowed() && ctx.ContextUser.Visibility != api.VisibleTypePublic {- ctx.APIError(http.StatusForbidden, "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.APIError(http.StatusForbidden, "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.APIError(http.StatusForbidden, "token scope is limited to public packages")- return+ for _, category := range requiredScopeCategories {+ switch category {+ case auth_model.AccessTokenScopeCategoryRepository:+ if !ctx.TokenCanAccessRepo(ctx.Repo.Repository) {+ ctx.APIError(http.StatusForbidden, "token scope is limited to public repos")+ return+ }+ case auth_model.AccessTokenScopeCategoryIssue:+ if !ctx.TokenCanAccessRepo(ctx.Repo.Repository) {+ ctx.APIError(http.StatusForbidden, "token scope is limited to public issues")+ return+ }+ case auth_model.AccessTokenScopeCategoryOrganization:+ orgPrivate := ctx.Org.Organization != nil && !ctx.Org.Organization.Visibility.IsPublic()+ userOrgPrivate := ctx.ContextUser != nil && ctx.ContextUser.IsOrganization() && !ctx.ContextUser.Visibility.IsPublic()+ if orgPrivate || userOrgPrivate {+ ctx.APIError(http.StatusForbidden, "token scope is limited to public orgs")+ return+ }+ case auth_model.AccessTokenScopeCategoryUser:+ if ctx.ContextUser != nil && ctx.ContextUser.IsTokenAccessAllowed() && !ctx.ContextUser.Visibility.IsPublic() {+ ctx.APIError(http.StatusForbidden, "token scope is limited to public users")+ return+ }+ case auth_model.AccessTokenScopeCategoryActivityPub:+ if ctx.ContextUser != nil && ctx.ContextUser.IsTokenAccessAllowed() && !ctx.ContextUser.Visibility.IsPublic() {+ ctx.APIError(http.StatusForbidden, "token scope is limited to public activitypub")+ return+ }+ case auth_model.AccessTokenScopeCategoryNotification:+ if !ctx.TokenCanAccessRepo(ctx.Repo.Repository) {+ ctx.APIError(http.StatusForbidden, "token scope is limited to public notifications")+ return+ }@@ -13,6 +13,7 @@ import ( "strconv" "strings" + repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/cache"@@ -47,6 +48,12 @@ type APIContext struct { PublicOnly bool // Whether the request is for a public endpoint } +// TokenCanAccessRepo reports whether the current API token is allowed to access the repository.+// A public-only token cannot reach a private repo; any other token is unrestricted by this check.+func (ctx *APIContext) TokenCanAccessRepo(repo *repo_model.Repository) bool {+ return repo == nil || !ctx.PublicOnly || !repo.IsPrivate+}+ func init() { web.RegisterResponseStatusProvider[*APIContext](func(req *http.Request) web_types.ResponseStatusProvider { return req.Context().Value(apiContextKey).(*APIContext)@@ -134,9 +134,6 @@ func Search(ctx *context.APIContext) { // "$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),@@ -152,6 +149,7 @@ func Search(ctx *context.APIContext) { StarredByID: ctx.FormInt64("starredBy"), IncludeDescription: ctx.FormBool("includeDesc"), }+ opts.ApplyPublicOnly(ctx.PublicOnly) if ctx.FormString("template") != "" { opts.Template = optional.Some(ctx.FormBool("template"))@@ -557,6 +555,10 @@ func GetByID(ctx *context.APIContext) { } return }+ if !ctx.TokenCanAccessRepo(repo) {+ ctx.APIErrorNotFound()+ return+ } permission, err := access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer) if err != nil {@@ -1309,6 +1311,7 @@ func ListRepoActivityFeeds(ctx *context.APIContext) { Date: ctx.FormString("date"), ListOptions: listOptions, }+ opts.ApplyPublicOnly(ctx.PublicOnly) feeds, count, err := feed_service.GetFeeds(ctx, opts) if err != nil {@@ -291,7 +291,9 @@ func checkTokenPublicOnly() func(ctx *context.APIContext) { return } case auth_model.AccessTokenScopeCategoryPackage:- if ctx.Package != nil && ctx.Package.Owner.Visibility.IsPrivate() {+ // a public-only token must not reach limited-visibility owners either,+ // matching the org/user public-only enforcement above+ if ctx.Package != nil && !ctx.Package.Owner.Visibility.IsPublic() { ctx.APIError(http.StatusForbidden, "token scope is limited to public packages") return }@@ -73,7 +73,9 @@ func reqPackageAccess(accessMode perm.AccessMode) func(ctx *context.Context) { } if publicOnly {- if ctx.Package != nil && ctx.Package.Owner.Visibility.IsPrivate() {+ // a public-only token must not reach limited-visibility owners either,+ // matching how orgs/users are enforced elsewhere in this file+ if ctx.Package != nil && !ctx.Package.Owner.Visibility.IsPublic() { ctx.HTTPError(http.StatusForbidden, "reqToken", "token scope is limited to public packages") return }AI-assisted change f09bd2b653189335288bbbb4b4d5b95da6af97cc24bc24693b42121bc35d732c · Fix 0e2dcec58f5b408784e998371eb9a9bd4544ceaf6c21db56efca7402f4c8b04d