fix(api): hide limited users from restricted viewers (#39004)

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: silverwind <me@silverwind.io>
This commit is contained in:
bircni
2026-08-22 10:08:36 +02:00
committed by GitHub
parent f7072b0305
commit 6bb6ce678b
4 changed files with 84 additions and 25 deletions
+1 -1
View File
@@ -221,7 +221,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)
+3 -9
View File
@@ -652,18 +652,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),
))))
}
+4 -15
View File
@@ -988,19 +988,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()
}
}
@@ -1166,7 +1155,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)
@@ -1790,7 +1779,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() {
@@ -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{}))
}