Files
Gitea/models/repo/repo_list_test.go
T
Giteabot de4b8277e9 fix: various security fixes (#38406) (#38426)
Backport #38406 by @bircni

Addresses a batch of privately reported security issues, grouped by
area:

- **SSRF** - migration PR-patch/asset fetches, OAuth2 avatar & OpenID
discovery, pull-mirror URL re-validation, and the outbound proxy path.
- **Access-token scope** - prevent scope escalation on token creation;
keep public-only tokens confined (feeds, packages, Actions listings,
star/watch lists, limited/private owners).
- **Access control / disclosure** - go-get default-branch leak, webhook
authorization-header leak, watch clearing on private transitions,
label/attachment scoping.
- **Denial of service** - input bounds for npm dist-tags, Debian control
files, Arch file lists, and SSH keys.

### 📌 Attention for site admins

Not breaking - existing configs keep working - but two changes are worth
a look:

- **New SSRF protection** Outbound requests (migrations, OAuth2 avatars,
OpenID discovery, pull mirrors, proxy path) are now validated against
the allow/block host lists. If your instance legitimately reaches
internal hosts, you may need to add them to
`[security].ALLOWED_HOST_LIST` (and the relevant `ALLOW_LOCALNETWORKS`
settings).
- **Deprecation** `[webhook].ALLOWED_HOST_LIST` is deprecated and will
be removed in a future release. Use `[security].ALLOWED_HOST_LIST`
instead; the old key still works for now.

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: TheFox0x7 <thefox0x7@gmail.com>
Co-authored-by: techknowlogick <techknowlogick@gitea.io>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
2026-07-12 17:41:58 +00:00

517 lines
19 KiB
Go

// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo_test
import (
"strings"
"testing"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/structs"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func getTestCases() []struct {
name string
opts repo_model.SearchRepoOptions
count int
} {
testCases := []struct {
name string
opts repo_model.SearchRepoOptions
count int
}{
{
name: "PublicRepositoriesByName",
opts: repo_model.SearchRepoOptions{Keyword: "big_test_", ListOptions: db.ListOptions{PageSize: 10}, Collaborate: optional.Some(false)},
count: 7,
},
{
name: "PublicAndPrivateRepositoriesByName",
opts: repo_model.SearchRepoOptions{Keyword: "big_test_", ListOptions: db.ListOptions{Page: 1, PageSize: 10}, Private: true, Collaborate: optional.Some(false)},
count: 14,
},
{
name: "PublicAndPrivateRepositoriesByNameWithPagesizeLimitFirstPage",
opts: repo_model.SearchRepoOptions{Keyword: "big_test_", ListOptions: db.ListOptions{Page: 1, PageSize: 5}, Private: true, Collaborate: optional.Some(false)},
count: 14,
},
{
name: "PublicAndPrivateRepositoriesByNameWithPagesizeLimitSecondPage",
opts: repo_model.SearchRepoOptions{Keyword: "big_test_", ListOptions: db.ListOptions{Page: 2, PageSize: 5}, Private: true, Collaborate: optional.Some(false)},
count: 14,
},
{
name: "PublicAndPrivateRepositoriesByNameWithPagesizeLimitThirdPage",
opts: repo_model.SearchRepoOptions{Keyword: "big_test_", ListOptions: db.ListOptions{Page: 3, PageSize: 5}, Private: true, Collaborate: optional.Some(false)},
count: 14,
},
{
name: "PublicAndPrivateRepositoriesByNameWithPagesizeLimitFourthPage",
opts: repo_model.SearchRepoOptions{Keyword: "big_test_", ListOptions: db.ListOptions{Page: 3, PageSize: 5}, Private: true, Collaborate: optional.Some(false)},
count: 14,
},
{
name: "PublicRepositoriesOfUser",
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 15, Collaborate: optional.Some(false)},
count: 2,
},
{
name: "PublicRepositoriesOfUser2",
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 18, Collaborate: optional.Some(false)},
count: 0,
},
{
name: "PublicRepositoriesOfOrg3",
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 20, Collaborate: optional.Some(false)},
count: 2,
},
{
name: "PublicAndPrivateRepositoriesOfUser",
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 15, Private: true, Collaborate: optional.Some(false)},
count: 4,
},
{
name: "PublicAndPrivateRepositoriesOfUser2",
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 18, Private: true, Collaborate: optional.Some(false)},
count: 0,
},
{
name: "PublicAndPrivateRepositoriesOfOrg3",
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 20, Private: true, Collaborate: optional.Some(false)},
count: 4,
},
{
name: "PublicRepositoriesOfUserIncludingCollaborative",
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 15},
count: 5,
},
{
name: "PublicRepositoriesOfUser2IncludingCollaborative",
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 18},
count: 1,
},
{
name: "PublicRepositoriesOfOrg3IncludingCollaborative",
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 20},
count: 3,
},
{
name: "PublicAndPrivateRepositoriesOfUserIncludingCollaborative",
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 15, Private: true},
count: 9,
},
{
name: "PublicAndPrivateRepositoriesOfUser2IncludingCollaborative",
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 18, Private: true},
count: 4,
},
{
name: "PublicAndPrivateRepositoriesOfOrg3IncludingCollaborative",
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 20, Private: true},
count: 7,
},
{
name: "PublicRepositoriesOfOrganization",
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 17, Collaborate: optional.Some(false)},
count: 1,
},
{
name: "PublicAndPrivateRepositoriesOfOrganization",
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 17, Private: true, Collaborate: optional.Some(false)},
count: 2,
},
{
name: "AllPublic/PublicRepositoriesByName",
opts: repo_model.SearchRepoOptions{Keyword: "big_test_", ListOptions: db.ListOptions{PageSize: 10}, AllPublic: true, Collaborate: optional.Some(false)},
count: 7,
},
{
name: "AllPublic/PublicAndPrivateRepositoriesByName",
opts: repo_model.SearchRepoOptions{Keyword: "big_test_", ListOptions: db.ListOptions{Page: 1, PageSize: 10}, Private: true, AllPublic: true, Collaborate: optional.Some(false)},
count: 14,
},
{
name: "AllPublic/PublicRepositoriesOfUserIncludingCollaborative",
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 15, AllPublic: true, Template: optional.Some(false)},
count: 34,
},
{
name: "AllPublic/PublicAndPrivateRepositoriesOfUserIncludingCollaborative",
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 15, Private: true, AllPublic: true, AllLimited: true, Template: optional.Some(false)},
count: 39,
},
{
name: "AllPublic/PublicAndPrivateRepositoriesOfUserIncludingCollaborativeByName",
opts: repo_model.SearchRepoOptions{Keyword: "test", ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 15, Private: true, AllPublic: true},
count: 15,
},
{
name: "AllPublic/PublicAndPrivateRepositoriesOfUser2IncludingCollaborativeByName",
opts: repo_model.SearchRepoOptions{Keyword: "test", ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 18, Private: true, AllPublic: true},
count: 13,
},
{
name: "AllPublic/PublicRepositoriesOfOrganization",
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 17, AllPublic: true, Collaborate: optional.Some(false), Template: optional.Some(false)},
count: 34,
},
{
name: "AllTemplates",
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, Template: optional.Some(true)},
count: 2,
},
{
name: "OwnerSlashRepoSearch",
opts: repo_model.SearchRepoOptions{Keyword: "user/repo2", ListOptions: db.ListOptions{Page: 1, PageSize: 10}, Private: true, OwnerID: 0},
count: 2,
},
{
name: "OwnerSlashSearch",
opts: repo_model.SearchRepoOptions{Keyword: "user20/", ListOptions: db.ListOptions{Page: 1, PageSize: 10}, Private: true, OwnerID: 0},
count: 4,
},
}
return testCases
}
func TestSearchRepository(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
t.Run("SearchRepositoryPublic", testSearchRepositoryPublic)
t.Run("SearchRepositoryPublicRestricted", testSearchRepositoryRestricted)
t.Run("SearchRepositoryPrivate", testSearchRepositoryPrivate)
t.Run("SearchRepositoryNonExistingOwner", testSearchRepositoryNonExistingOwner)
t.Run("SearchRepositoryWithInDescription", testSearchRepositoryWithInDescription)
t.Run("SearchRepositoryNotInDescription", testSearchRepositoryNotInDescription)
t.Run("SearchRepositoryCases", testSearchRepositoryCases)
}
func testSearchRepositoryPublic(t *testing.T) {
// test search public repository on explore page
repos, count, err := repo_model.SearchRepositoryByName(t.Context(), repo_model.SearchRepoOptions{
ListOptions: db.ListOptions{
Page: 1,
PageSize: 10,
},
Keyword: "repo_12",
Collaborate: optional.Some(false),
})
assert.NoError(t, err)
if assert.Len(t, repos, 1) {
assert.Equal(t, "test_repo_12", repos[0].Name)
}
assert.Equal(t, int64(1), count)
repos, count, err = repo_model.SearchRepositoryByName(t.Context(), repo_model.SearchRepoOptions{
ListOptions: db.ListOptions{
Page: 1,
PageSize: 10,
},
Keyword: "test_repo",
Collaborate: optional.Some(false),
})
assert.NoError(t, err)
assert.Equal(t, int64(2), count)
assert.Len(t, repos, 2)
}
func testSearchRepositoryRestricted(t *testing.T) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
restrictedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29, IsRestricted: true})
performSearch := func(t *testing.T, user *user_model.User) (publicRepoIDs []int64) {
repos, count, err := repo_model.SearchRepositoryByName(t.Context(), repo_model.SearchRepoOptions{
ListOptions: db.ListOptions{Page: 1, PageSize: 10000},
Actor: user,
})
require.NoError(t, err)
assert.Len(t, repos, int(count))
for _, repo := range repos {
require.NoError(t, repo.LoadOwner(t.Context()))
if repo.Owner.Visibility == structs.VisibleTypePublic && !repo.IsPrivate {
publicRepoIDs = append(publicRepoIDs, repo.ID)
}
}
return publicRepoIDs
}
normalPublicRepoIDs := performSearch(t, user2)
require.Greater(t, len(normalPublicRepoIDs), 10) // quite a lot
t.Run("RestrictedUser-NoSignInRequirement", func(t *testing.T) {
// restricted user can also see public repositories if no "required sign-in"
repoIDs := performSearch(t, restrictedUser)
assert.ElementsMatch(t, normalPublicRepoIDs, repoIDs)
})
defer test.MockVariableValue(&setting.Service.RequireSignInViewStrict, true)()
t.Run("NormalUser-RequiredSignIn", func(t *testing.T) {
// normal user can still see all public repos, not affected by "required sign-in"
repoIDs := performSearch(t, user2)
assert.ElementsMatch(t, normalPublicRepoIDs, repoIDs)
})
t.Run("RestrictedUser-RequiredSignIn", func(t *testing.T) {
// restricted user can see only their own repo
repoIDs := performSearch(t, restrictedUser)
assert.Equal(t, []int64{4}, repoIDs)
})
}
func testSearchRepositoryPrivate(t *testing.T) {
// test search private repository on explore page
repos, count, err := repo_model.SearchRepositoryByName(t.Context(), repo_model.SearchRepoOptions{
ListOptions: db.ListOptions{
Page: 1,
PageSize: 10,
},
Keyword: "repo_13",
Private: true,
Collaborate: optional.Some(false),
})
assert.NoError(t, err)
if assert.Len(t, repos, 1) {
assert.Equal(t, "test_repo_13", repos[0].Name)
}
assert.Equal(t, int64(1), count)
repos, count, err = repo_model.SearchRepositoryByName(t.Context(), repo_model.SearchRepoOptions{
ListOptions: db.ListOptions{
Page: 1,
PageSize: 10,
},
Keyword: "test_repo",
Private: true,
Collaborate: optional.Some(false),
})
assert.NoError(t, err)
assert.Equal(t, int64(3), count)
assert.Len(t, repos, 3)
}
func testSearchRepositoryNonExistingOwner(t *testing.T) {
repos, count, err := repo_model.SearchRepositoryByName(t.Context(), repo_model.SearchRepoOptions{OwnerID: unittest.NonexistentID})
assert.NoError(t, err)
assert.Empty(t, repos)
assert.Equal(t, int64(0), count)
}
func testSearchRepositoryWithInDescription(t *testing.T) {
repos, count, err := repo_model.SearchRepository(t.Context(), repo_model.SearchRepoOptions{
ListOptions: db.ListOptions{
Page: 1,
PageSize: 10,
},
Keyword: "description_14",
Collaborate: optional.Some(false),
IncludeDescription: true,
})
assert.NoError(t, err)
if assert.Len(t, repos, 1) {
assert.Equal(t, "test_repo_14", repos[0].Name)
}
assert.Equal(t, int64(1), count)
}
func testSearchRepositoryNotInDescription(t *testing.T) {
repos, count, err := repo_model.SearchRepository(t.Context(), repo_model.SearchRepoOptions{
ListOptions: db.ListOptions{
Page: 1,
PageSize: 10,
},
Keyword: "description_14",
Collaborate: optional.Some(false),
IncludeDescription: false,
})
assert.NoError(t, err)
assert.Empty(t, repos)
assert.Equal(t, int64(0), count)
}
func testSearchRepositoryCases(t *testing.T) {
testCases := getTestCases()
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
repos, count, err := repo_model.SearchRepositoryByName(t.Context(), testCase.opts)
assert.NoError(t, err)
assert.Equal(t, int64(testCase.count), count)
page := testCase.opts.Page
if page <= 0 {
page = 1
}
expectedLen := testCase.opts.PageSize
if testCase.opts.PageSize*page > testCase.count+testCase.opts.PageSize {
expectedLen = 0
} else if testCase.opts.PageSize*page > testCase.count {
expectedLen = testCase.count % testCase.opts.PageSize
}
if assert.Len(t, repos, expectedLen) {
for _, repo := range repos {
assert.NotEmpty(t, repo.Name)
if len(testCase.opts.Keyword) > 0 {
// Keyword match condition is different for search terms of form "owner/repo"
if strings.Count(testCase.opts.Keyword, "/") == 1 {
// May still match as a whole...
wholeMatch := strings.Contains(repo.Name, testCase.opts.Keyword)
pieces := strings.Split(testCase.opts.Keyword, "/")
ownerName := pieces[0]
repoName := pieces[1]
// ... or match in parts
splitMatch := strings.Contains(repo.OwnerName, ownerName) && strings.Contains(repo.Name, repoName)
assert.True(t, wholeMatch || splitMatch, "Keyword '%s' does not match repo '%s/%s'", testCase.opts.Keyword, repo.Owner.Name, repo.Name)
} else {
assert.Contains(t, repo.Name, testCase.opts.Keyword)
}
}
if !testCase.opts.Private {
assert.False(t, repo.IsPrivate)
}
if testCase.opts.Fork.Value() && testCase.opts.Mirror.Value() {
assert.True(t, repo.IsFork && repo.IsMirror)
} else {
if testCase.opts.Fork.Has() {
assert.Equal(t, testCase.opts.Fork.Value(), repo.IsFork)
}
if testCase.opts.Mirror.Has() {
assert.Equal(t, testCase.opts.Mirror.Value(), repo.IsMirror)
}
}
if testCase.opts.OwnerID > 0 && !testCase.opts.AllPublic {
if testCase.opts.Collaborate.Has() {
if testCase.opts.Collaborate.Value() {
assert.NotEqual(t, testCase.opts.OwnerID, repo.Owner.ID)
} else {
assert.Equal(t, testCase.opts.OwnerID, repo.Owner.ID)
}
}
}
}
}
})
}
}
func TestCountRepository(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
testCases := getTestCases()
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
count, err := repo_model.CountRepository(t.Context(), testCase.opts)
assert.NoError(t, err)
assert.Equal(t, int64(testCase.count), count)
})
}
}
func TestSearchRepositoryByTopicName(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
testCases := []struct {
name string
opts repo_model.SearchRepoOptions
count int
}{
{
name: "AllPublic/SearchPublicRepositoriesFromTopicAndName",
opts: repo_model.SearchRepoOptions{OwnerID: 21, AllPublic: true, Keyword: "graphql"},
count: 2,
},
{
name: "AllPublic/OnlySearchPublicRepositoriesFromTopic",
opts: repo_model.SearchRepoOptions{OwnerID: 21, AllPublic: true, Keyword: "graphql", TopicOnly: true},
count: 1,
},
{
name: "AllPublic/OnlySearchMultipleKeywordPublicRepositoriesFromTopic",
opts: repo_model.SearchRepoOptions{OwnerID: 21, AllPublic: true, Keyword: "graphql,golang", TopicOnly: true},
count: 2,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
_, count, err := repo_model.SearchRepositoryByName(t.Context(), testCase.opts)
assert.NoError(t, err)
assert.Equal(t, int64(testCase.count), count)
})
}
}
func TestFindUserActionsAccessibleOwnerRepoIDs(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
// user2 is on org3's owner team, so it can access org3's private repo3 (which has the actions unit)
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
// org3 is a public org owning repo3 (private) and repo32 (public), both with the actions unit
const orgID = 3
all, err := repo_model.SearchRepositoryIDsByCondition(t.Context(), repo_model.UserActionsAccessibleOwnerRepoCond(orgID, user, false))
require.NoError(t, err)
assert.Contains(t, all, int64(3), "without public-only the private repo's actions are listed")
publicOnly, err := repo_model.SearchRepositoryIDsByCondition(t.Context(), repo_model.UserActionsAccessibleOwnerRepoCond(orgID, user, true))
require.NoError(t, err)
assert.NotContains(t, publicOnly, int64(3), "a public-only token must not list a private repo's actions")
assert.Contains(t, publicOnly, int64(32), "a public repo under a public owner stays listed")
}
// TestUserOrgUnitRepoCondTeamAuthorize pins the team.authorize behavior of userOrgTeamUnitRepoBuilder
// (exercised through UserOrgUnitRepoCond): an admin/owner team grants every unit even without an explicit
// team_unit row, while a non-admin team only grants a unit it has an explicit row for. This guards both
// directions — hiding repos from admin-team members, and over-broadening a plain team's access.
func TestUserOrgUnitRepoCondTeamAuthorize(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
accessibleRepoIDs := func(userID, orgID int64, unitType unit.Type) []int64 {
ids, err := repo_model.SearchRepositoryIDsByCondition(t.Context(),
repo_model.UserOrgUnitRepoCond("`repository`.id", userID, orgID, unitType))
require.NoError(t, err)
return ids
}
// Case A: user18 is only on org17's owner team (team5, authorize=owner), linked to the private repo24
// but with no Actions team_unit row. The owner authorize must still grant it, mirroring the runtime
// HasAdminAccess() short-circuit in access.GetIndividualUserRepoPermission.
assert.Contains(t, accessibleRepoIDs(18, 17, unit.TypeActions), int64(24),
"an owner team grants a unit it has no explicit team_unit row for")
// Cases B and C share one subject so the team_unit row is the only difference: user4 is only on org3's
// write team (team2, authorize=write, non-admin), linked to the private repo3. team2 has an explicit
// Projects row but none for Actions.
assert.Contains(t, accessibleRepoIDs(4, 3, unit.TypeProjects), int64(3),
"a non-admin team grants a unit it has an explicit team_unit row for")
assert.NotContains(t, accessibleRepoIDs(4, 3, unit.TypeActions), int64(3),
"a non-admin team must NOT grant a unit it has no team_unit row for")
}