From 1e86e1e45afe27f29f197bc65fa26f7c6736cfdf Mon Sep 17 00:00:00 2001 From: Giteabot Date: Sat, 22 Aug 2026 03:55:16 -0700 Subject: [PATCH] fix(api): hide limited users from restricted viewers (#39004) (#39039) Backport #39004 by @bircni Use the canonical profile-visibility check for user API content and prevent restricted users from enumerating public repositories owned by limited users. This keeps feeds, heatmaps, keys, and issue search consistent with profile visibility. Co-authored-by: bircni Co-authored-by: silverwind --- models/issues/issue_search.go | 2 +- models/repo/repo_list.go | 12 +-- routers/api/v1/api.go | 19 +---- tests/integration/api_user_visibility_test.go | 76 +++++++++++++++++++ 4 files changed, 84 insertions(+), 25 deletions(-) create mode 100644 tests/integration/api_user_visibility_test.go diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go index bc39199766e..aeec3781f9a 100644 --- a/models/issues/issue_search.go +++ b/models/issues/issue_search.go @@ -220,7 +220,7 @@ func applyRepoConditions(sess db.Session, opts *IssuesOptions) { if opts.RepoCond == nil { opts.RepoCond = builder.NewCond() } - opts.RepoCond = opts.RepoCond.Or(builder.In("issue.repo_id", builder.Select("id").From("repository").Where(builder.Eq{"is_private": false}))) + opts.RepoCond = opts.RepoCond.Or(builder.In("issue.repo_id", builder.Select("id").From("repository").Where(repo_model.PublicRepoUnderPublicOwnerCond()))) } if opts.RepoCond != nil { sess.And(opts.RepoCond) diff --git a/models/repo/repo_list.go b/models/repo/repo_list.go index 003f01470b6..4cad9a83177 100644 --- a/models/repo/repo_list.go +++ b/models/repo/repo_list.go @@ -655,18 +655,12 @@ func SearchRepositoryIDsByCondition(ctx context.Context, cond builder.Cond) ([]i Find(&repoIDs) } -func userAllPublicRepoCond(cond builder.Cond, orgVisibilityLimit []structs.VisibleType) builder.Cond { +func userAllPublicRepoCond(cond builder.Cond, ownerVisibilityLimit []structs.VisibleType) builder.Cond { return cond.Or(builder.And( builder.Eq{"`repository`.is_private": false}, + // Exclude owners who are not visible to the caller. builder.NotIn("`repository`.owner_id", builder.Select("id").From("`user`").Where( - builder.Or( - builder.And( - builder.Eq{"type": user_model.UserTypeOrganization}, - builder.In("visibility", orgVisibilityLimit)), - builder.And( - builder.Neq{"type": user_model.UserTypeOrganization}, - builder.Neq{"visibility": structs.VisibleTypePublic}), - ), + builder.In("visibility", ownerVisibilityLimit), )))) } diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 1961c0062a3..427a84a02cf 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -926,19 +926,8 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.APIC func individualPermsChecker(ctx *context.APIContext) { // org permissions have been checked in context.OrgAssignment(), but individual permissions haven't been checked. - if ctx.ContextUser.IsIndividual() { - switch ctx.ContextUser.Visibility { - case api.VisibleTypePrivate: - if ctx.Doer == nil || (ctx.ContextUser.ID != ctx.Doer.ID && !ctx.Doer.IsAdmin) { - ctx.APIErrorNotFound() - return - } - case api.VisibleTypeLimited: - if ctx.Doer == nil { - ctx.APIErrorNotFound() - return - } - } + if ctx.ContextUser.IsIndividual() && !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) { + ctx.APIErrorNotFound() } } @@ -1102,7 +1091,7 @@ func Routes() *web.Router { m.Get("/starred", reqStarsEnabled(), user.GetStarredRepos) m.Get("/subscriptions", user.GetWatchedRepos) - }, context.UserAssignmentAPI(), checkTokenPublicOnly()) + }, context.UserAssignmentAPI(), checkTokenPublicOnly(), individualPermsChecker) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken()) // Users (requires user scope) @@ -1710,7 +1699,7 @@ func Routes() *web.Router { 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(), checkTokenPublicOnly()) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), context.UserAssignmentAPI(), checkTokenPublicOnly(), individualPermsChecker) 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() { diff --git a/tests/integration/api_user_visibility_test.go b/tests/integration/api_user_visibility_test.go new file mode 100644 index 00000000000..cd044fc4cc3 --- /dev/null +++ b/tests/integration/api_user_visibility_test.go @@ -0,0 +1,76 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "testing" + + auth_model "gitea.dev/models/auth" + "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" + api "gitea.dev/modules/structs" + "gitea.dev/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIRestrictedUserLimitedOwner(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"}) + owner.Visibility = api.VisibleTypeLimited + require.NoError(t, user_model.UpdateUserCols(t.Context(), owner, "visibility")) + + restrictedToken := getUserToken(t, "user29", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadIssue, auth_model.AccessTokenScopeReadOrganization) + for _, path := range []string{ + "/api/v1/users/user2/activities/feeds", + "/api/v1/users/user2/heatmap", + "/api/v1/users/user2/keys", + "/api/v1/users/user2/gpg_keys", + "/api/v1/users/user2/orgs", + } { + req := NewRequest(t, "GET", path).AddTokenAuth(restrictedToken) + MakeRequest(t, req, http.StatusNotFound) + } + + issueSearch := url.URL{Path: "/api/v1/repos/issues/search"} + issueSearch.RawQuery = url.Values{"owner": {"user2"}}.Encode() + req := NewRequest(t, "GET", issueSearch.String()).AddTokenAuth(restrictedToken) + resp := MakeRequest(t, req, http.StatusOK) + assert.Empty(t, DecodeJSON(t, resp, []*api.Issue{})) + + issueSearch.RawQuery = url.Values{"limit": {"100"}, "type": {"issues"}}.Encode() + req = NewRequest(t, "GET", issueSearch.String()).AddTokenAuth(restrictedToken) + resp = MakeRequest(t, req, http.StatusOK) + issues := DecodeJSON(t, resp, []*api.Issue{}) + require.NotEmpty(t, issues) + for _, issue := range issues { + assert.NotEqual(t, owner.Name, issue.Repo.Owner) + } + + restrictedSession := loginUser(t, "user29") + req = NewRequest(t, "GET", "/issues/search?owner=user2") + resp = restrictedSession.MakeRequest(t, req, http.StatusOK) + assert.Empty(t, DecodeJSON(t, resp, []*api.Issue{})) + + viewerToken := getUserToken(t, "user4", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadIssue, auth_model.AccessTokenScopeReadOrganization) + for _, path := range []string{ + "/api/v1/users/user2/activities/feeds", + "/api/v1/users/user2/heatmap", + "/api/v1/users/user2/keys", + "/api/v1/users/user2/gpg_keys", + "/api/v1/users/user2/orgs", + } { + req := NewRequest(t, "GET", path).AddTokenAuth(viewerToken) + MakeRequest(t, req, http.StatusOK) + } + + issueSearch.RawQuery = url.Values{"owner": {"user2"}}.Encode() + req = NewRequest(t, "GET", issueSearch.String()).AddTokenAuth(viewerToken) + resp = MakeRequest(t, req, http.StatusOK) + assert.NotEmpty(t, DecodeJSON(t, resp, []*api.Issue{})) +}