fix: enforce public-only token scope and harden push options / locale parsing (#38323) (#38399)

Backport #38323 by @bircni

Three independent security hardening fixes, each with a regression test:

- **Locale DoS:** the `Locale` middleware passed the raw
`Accept-Language` header to `ParseAcceptLanguage`, whose guard only
counts `-` while the scanner aliases `_` to `-` — a large `_`-separated
header on an unauthenticated request burned CPU. The header is now
length-bounded before parsing.
- **Public-only token scope:** `GET /teams/{id}/repos`,
`.../repos/{org}/{repo}`, `/teams/{id}/activities/feeds`, and
`/users/{username}/orgs/{org}/permissions` still returned private
repo/activity/permission data to a public-only token. They now filter
via `TokenCanAccessRepo` / `ApplyPublicOnly` and reject non-public org
permissions.
- **Push-option visibility:** `repo.private` / `repo.template` push
options were applied to any existing repo, letting an owner/admin
silently flip visibility bypassing audit, webhooks, and notifications.
They are now honored only on push-to-create.

Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
Giteabot
2026-07-10 10:07:04 -07:00
committed by GitHub
parent 8b4252e7f6
commit af7ba2edef
8 changed files with 207 additions and 17 deletions
+7
View File
@@ -145,6 +145,13 @@ func GetUserOrgsPermissions(ctx *context.APIContext) {
op := api.OrganizationPermissions{}
// A public-only token must not disclose membership/permission details of a
// non-public org, even for the token owner's own private orgs.
if ctx.PublicOnly && !o.Visibility.IsPublic() {
ctx.APIErrorNotFound()
return
}
if !organization.HasOrgOrUserVisible(ctx, o, ctx.Doer) {
ctx.APIErrorNotFound()
return
+25 -5
View File
@@ -567,10 +567,20 @@ func GetTeamRepos(ctx *context.APIContext) {
team := ctx.Org.Team
listOptions := utils.GetListOptions(ctx)
teamRepos, err := repo_model.GetTeamRepositories(ctx, &repo_model.SearchTeamRepoOptions{
// A public-only token must not expose (or count) private repos, even when the
// doer owning the token otherwise has access to them, so filter them out at the
// query level to keep the returned page and the total-count header consistent.
searchOpts := &repo_model.SearchTeamRepoOptions{
ListOptions: listOptions,
TeamID: team.ID,
})
PublicOnly: ctx.PublicOnly,
}
teamRepos, err := repo_model.GetTeamRepositories(ctx, searchOpts)
if err != nil {
ctx.APIErrorInternal(err)
return
}
count, err := repo_model.CountTeamRepositories(ctx, searchOpts)
if err != nil {
ctx.APIErrorInternal(err)
return
@@ -584,14 +594,16 @@ func GetTeamRepos(ctx *context.APIContext) {
}
// A team's repo list is reachable by non-team-members through the team's
// visibility tier, so never expose repos (incl. their names) the doer
// cannot access.
// cannot access. This per-repo visibility trim can't be expressed in the
// SQL count above without regressing per-unit public access, so for such
// non-members the total-count header may be a small upper bound.
if !permission.HasAnyUnitAccessOrPublicAccess() {
continue
}
repos = append(repos, convert.ToRepo(ctx, repo, permission))
}
ctx.SetLinkHeader(int64(team.NumRepos), listOptions.PageSize)
ctx.SetTotalCountHeader(int64(team.NumRepos))
ctx.SetLinkHeader(count, listOptions.PageSize)
ctx.SetTotalCountHeader(count)
ctx.JSON(http.StatusOK, repos)
}
@@ -630,6 +642,12 @@ func GetTeamRepo(ctx *context.APIContext) {
return
}
// A public-only token must not confirm the existence of a private repo.
if !ctx.TokenCanAccessRepo(repo) {
ctx.APIErrorNotFound()
return
}
if !organization.HasTeamRepo(ctx, ctx.Org.Team.OrgID, ctx.Org.Team.ID, repo.ID) {
ctx.APIErrorNotFound()
return
@@ -889,6 +907,8 @@ func ListTeamActivityFeeds(ctx *context.APIContext) {
Date: ctx.FormString("date"),
ListOptions: listOptions,
}
// A public-only token must not receive private activity entries.
opts.ApplyPublicOnly(ctx.PublicOnly)
feeds, count, err := feed_service.GetFeeds(ctx, opts)
if err != nil {
+10 -3
View File
@@ -160,10 +160,17 @@ func hookPostReceiveUpdateRepoByOptions(ctx *gitea_context.PrivateContext, opts
return false
}
// FIXME: these options are not quite right, for example: changing visibility should do more works than just setting the is_private flag
// These options should only be used for "push-to-create"
// Only honor these options while the repo is still empty (the push-to-create
// case). On a populated repo a bare "git push -o repo.private=..." would
// silently flip visibility, bypassing the audit log, webhooks and notifications.
if !repo.IsEmpty {
return true
}
// The repo is empty and being initialized by this push, so there is no
// dependent state (webhooks, notifications, visibility fan-out) to reconcile
// yet; setting the flags directly is sufficient in this push-to-create case.
if isPrivate.Has() && repo.IsPrivate != isPrivate.Value() {
// TODO: it needs to do more work
repo.IsPrivate = isPrivate.Value()
if err = repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil {
log.Error("failed to update repo is_private: %v", err)