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 <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
Giteabot
2026-08-22 03:55:16 -07:00
committed by GitHub
parent 0d946be8be
commit 1e86e1e45a
4 changed files with 84 additions and 25 deletions
+1 -1
View File
@@ -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)
+3 -9
View File
@@ -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),
))))
}
+4 -15
View File
@@ -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() {
@@ -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{}))
}