mirror of
https://github.com/go-gitea/gitea.git
synced 2026-07-10 19:29:52 +00:00
fix: enforce public-only token scope and harden push options / locale parsing (#38323)
- **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.
This commit is contained in:
@@ -31,18 +31,28 @@ func GetOrgRepositoryIDs(ctx context.Context, orgID int64) (repoIDs []int64, _ e
|
||||
type SearchTeamRepoOptions struct {
|
||||
db.ListOptions
|
||||
TeamID int64
|
||||
// PublicOnly restricts the result (and count) to non-private repositories.
|
||||
PublicOnly bool
|
||||
}
|
||||
|
||||
func (opts *SearchTeamRepoOptions) toCond() builder.Cond {
|
||||
cond := builder.NewCond()
|
||||
if opts.TeamID > 0 {
|
||||
cond = cond.And(builder.In("id",
|
||||
builder.Select("repo_id").
|
||||
From("team_repo").
|
||||
Where(builder.Eq{"team_id": opts.TeamID}),
|
||||
))
|
||||
}
|
||||
if opts.PublicOnly {
|
||||
cond = cond.And(builder.Eq{"is_private": false})
|
||||
}
|
||||
return cond
|
||||
}
|
||||
|
||||
// GetTeamRepositories returns paginated repositories in team of organization.
|
||||
func GetTeamRepositories(ctx context.Context, opts *SearchTeamRepoOptions) (RepositoryList, error) {
|
||||
sess := db.GetEngine(ctx)
|
||||
if opts.TeamID > 0 {
|
||||
sess = sess.In("id",
|
||||
builder.Select("repo_id").
|
||||
From("team_repo").
|
||||
Where(builder.Eq{"team_id": opts.TeamID}),
|
||||
)
|
||||
}
|
||||
sess := db.GetEngine(ctx).Where(opts.toCond())
|
||||
if opts.PageSize > 0 {
|
||||
sess.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize)
|
||||
}
|
||||
@@ -51,6 +61,11 @@ func GetTeamRepositories(ctx context.Context, opts *SearchTeamRepoOptions) (Repo
|
||||
Find(&repos)
|
||||
}
|
||||
|
||||
// CountTeamRepositories returns the number of repositories in team of organization matching opts.
|
||||
func CountTeamRepositories(ctx context.Context, opts *SearchTeamRepoOptions) (int64, error) {
|
||||
return db.GetEngine(ctx).Where(opts.toCond()).Count(new(Repository))
|
||||
}
|
||||
|
||||
// AccessibleReposEnvironment operations involving the repositories that are
|
||||
// accessible to a particular user
|
||||
type AccessibleReposEnvironment interface {
|
||||
|
||||
@@ -12,6 +12,24 @@ import (
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// maxAcceptLanguageLen bounds the Accept-Language header before it reaches
|
||||
// language.ParseAcceptLanguage. That parser has quadratic-time behavior on long
|
||||
// malformed inputs, and its built-in guard only counts "-" separators while the
|
||||
// scanner treats "_" as an alias for "-", so a "_"-heavy header slips past the
|
||||
// guard and burns CPU. Only the leading (highest-priority) languages are used, so
|
||||
// truncating a longer header is safe.
|
||||
const maxAcceptLanguageLen = 200
|
||||
|
||||
// parseAcceptLanguage parses the Accept-Language header after bounding its length
|
||||
// to avoid a quadratic-time DoS on attacker-controlled input.
|
||||
func parseAcceptLanguage(header string) []language.Tag {
|
||||
if len(header) > maxAcceptLanguageLen {
|
||||
header = header[:maxAcceptLanguageLen]
|
||||
}
|
||||
tags, _, _ := language.ParseAcceptLanguage(header)
|
||||
return tags
|
||||
}
|
||||
|
||||
// Locale handle locale
|
||||
func Locale(resp http.ResponseWriter, req *http.Request) translation.Locale {
|
||||
// 1. Check URL arguments.
|
||||
@@ -35,7 +53,7 @@ func Locale(resp http.ResponseWriter, req *http.Request) translation.Locale {
|
||||
// 3. Get language information from 'Accept-Language'.
|
||||
// The first element in the list is chosen to be the default language automatically.
|
||||
if len(lang) == 0 {
|
||||
tags, _, _ := language.ParseAcceptLanguage(req.Header.Get("Accept-Language"))
|
||||
tags := parseAcceptLanguage(req.Header.Get("Accept-Language"))
|
||||
tag := translation.Match(tags...)
|
||||
lang = tag.String()
|
||||
}
|
||||
|
||||
27
modules/web/middleware/locale_test.go
Normal file
27
modules/web/middleware/locale_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseAcceptLanguage(t *testing.T) {
|
||||
// a normal header is parsed and its leading language preserved
|
||||
tags := parseAcceptLanguage("de-DE,de;q=0.9,en;q=0.8")
|
||||
assert.NotEmpty(t, tags)
|
||||
assert.Equal(t, "de-DE", tags[0].String())
|
||||
|
||||
// an oversized "_"-separated header would drive ParseAcceptLanguage into its
|
||||
// quadratic-time path (the built-in guard only counts "-"); the length bound
|
||||
// keeps the input passed to the parser small so it cannot be used for a DoS.
|
||||
malicious := strings.Repeat("_aaaaaaaaa", 1<<16) // ~640 KiB, zero "-" characters
|
||||
assert.Greater(t, len(malicious), maxAcceptLanguageLen)
|
||||
tags = parseAcceptLanguage(malicious)
|
||||
// no panic / hang, and nothing meaningful is parsed out of the garbage
|
||||
assert.Empty(t, tags)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -5,9 +5,14 @@ package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/organization"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/tests"
|
||||
|
||||
@@ -95,6 +100,59 @@ func TestAPIActivityFeedsPublicOnly(t *testing.T) {
|
||||
assertPublicActivitiesOnly(t, activities)
|
||||
}
|
||||
|
||||
func TestAPIOrgPermissionsPublicOnly(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
// user2 is a member of the private org private_org35
|
||||
org := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "private_org35"})
|
||||
|
||||
// a full org-scoped token can read the membership permissions
|
||||
token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadOrganization)
|
||||
req := NewRequestf(t, "GET", "/api/v1/users/user2/orgs/%s/permissions", org.Name).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
// a public-only token must not disclose permissions for a private org
|
||||
publicToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopePublicOnly)
|
||||
req = NewRequestf(t, "GET", "/api/v1/users/user2/orgs/%s/permissions", org.Name).AddTokenAuth(publicToken)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestAPITeamReposPublicOnly(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
// team 1 (Owners of org3) has access to the private repos org3/repo3 and org3/repo5
|
||||
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 1})
|
||||
privateRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
|
||||
privateRepo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 5})
|
||||
|
||||
// a full org+repo scoped token sees the private repos
|
||||
token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopeReadRepository)
|
||||
req := NewRequestf(t, "GET", "/api/v1/teams/%d/repos", team.ID).AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
repos := DecodeJSON(t, resp, []api.Repository{})
|
||||
assert.Contains(t, repoNames(repos), privateRepo.FullName())
|
||||
|
||||
// a public-only token must not receive any private repo
|
||||
publicToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopeReadRepository, auth_model.AccessTokenScopePublicOnly)
|
||||
req = NewRequestf(t, "GET", "/api/v1/teams/%d/repos", team.ID).AddTokenAuth(publicToken)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
repos = DecodeJSON(t, resp, []api.Repository{})
|
||||
for _, repo := range repos {
|
||||
assert.False(t, repo.Private)
|
||||
}
|
||||
assert.NotContains(t, repoNames(repos), privateRepo.FullName())
|
||||
assert.NotContains(t, repoNames(repos), privateRepo2.FullName())
|
||||
// the total-count header must match the filtered page, otherwise it leaks the
|
||||
// number of hidden private repos
|
||||
assert.Equal(t, strconv.Itoa(len(repos)), resp.Header().Get("X-Total-Count"))
|
||||
|
||||
// the single-repo endpoint must not confirm a private repo for a public-only token
|
||||
req = NewRequestf(t, "GET", "/api/v1/teams/%d/repos/%s", team.ID, privateRepo.FullName()).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
req = NewRequestf(t, "GET", "/api/v1/teams/%d/repos/%s", team.ID, privateRepo.FullName()).AddTokenAuth(publicToken)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func assertPublicActivitiesOnly(t *testing.T, activities []api.Activity) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
@@ -140,6 +141,43 @@ func testGitPush(t *testing.T, u *url.URL) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestGitPushVisibilityOption(t *testing.T) {
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
repo, err := repo_service.CreateRepository(t.Context(), user, user, repo_service.CreateRepoOptions{
|
||||
Name: "repo-visibility-option",
|
||||
AutoInit: false,
|
||||
DefaultBranch: "master",
|
||||
IsPrivate: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, repo)
|
||||
|
||||
gitPath := t.TempDir()
|
||||
doGitInitTestRepository(gitPath)(t)
|
||||
|
||||
oldPath, oldUser := u.Path, u.User
|
||||
defer func() { u.Path, u.User = oldPath, oldUser }()
|
||||
u.Path = repo.FullName() + ".git"
|
||||
u.User = url.UserPassword(user.LowerName, userPassword)
|
||||
doGitAddRemote(gitPath, "origin", u)(t)
|
||||
|
||||
// The first push into an empty repository is a "push-to-create", so the
|
||||
// repo.private push option is honored to set the initial visibility.
|
||||
doGitPushTestRepository(gitPath, "origin", "master", "-o", "repo.private=true")(t)
|
||||
repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID})
|
||||
assert.True(t, repo.IsPrivate, "repo.private option should apply on push-to-create")
|
||||
|
||||
// The repository is now populated; a later push must NOT silently flip
|
||||
// visibility, otherwise a repo admin could change it bypassing the audit
|
||||
// trail, webhooks, and notifications a proper settings change would fire.
|
||||
doGitCreateBranch(gitPath, "branch2")(t)
|
||||
doGitPushTestRepository(gitPath, "origin", "branch2", "-o", "repo.private=false")(t)
|
||||
repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID})
|
||||
assert.True(t, repo.IsPrivate, "repo.private option must be ignored on an existing repository")
|
||||
})
|
||||
}
|
||||
|
||||
func runTestGitPush(t *testing.T, u *url.URL, gitOperation func(t *testing.T, gitPath string) (pushed, deleted []string)) {
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
repo, err := repo_service.CreateRepository(t.Context(), user, user, repo_service.CreateRepoOptions{
|
||||
|
||||
Reference in New Issue
Block a user