diff --git a/models/repo/org_repo.go b/models/repo/org_repo.go index ef4d9a4a622..5bf0a3e7d42 100644 --- a/models/repo/org_repo.go +++ b/models/repo/org_repo.go @@ -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 { diff --git a/modules/web/middleware/locale.go b/modules/web/middleware/locale.go index c376c21865c..572f938aff6 100644 --- a/modules/web/middleware/locale.go +++ b/modules/web/middleware/locale.go @@ -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() } diff --git a/modules/web/middleware/locale_test.go b/modules/web/middleware/locale_test.go new file mode 100644 index 00000000000..355ac943386 --- /dev/null +++ b/modules/web/middleware/locale_test.go @@ -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) +} diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index 8f4d19719fd..c4f513d0bbe 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -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 diff --git a/routers/api/v1/org/team.go b/routers/api/v1/org/team.go index bc6bb54e90c..f15196e484b 100644 --- a/routers/api/v1/org/team.go +++ b/routers/api/v1/org/team.go @@ -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 { diff --git a/routers/private/hook_post_receive.go b/routers/private/hook_post_receive.go index e19bee3e7d8..d6b5dfcf86b 100644 --- a/routers/private/hook_post_receive.go +++ b/routers/private/hook_post_receive.go @@ -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) diff --git a/tests/integration/api_public_only_test.go b/tests/integration/api_public_only_test.go index 13a764fcc55..3c35d66e542 100644 --- a/tests/integration/api_public_only_test.go +++ b/tests/integration/api_public_only_test.go @@ -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() diff --git a/tests/integration/git_push_test.go b/tests/integration/git_push_test.go index eee80b191cb..8be4494eae0 100644 --- a/tests/integration/git_push_test.go +++ b/tests/integration/git_push_test.go @@ -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{