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{})) +}