mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-22 20:43:38 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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{}))
|
||||
}
|
||||
Reference in New Issue
Block a user