From f2a1271f164569264c378fad720b0c000fff3336 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Mon, 18 May 2026 11:36:42 -0700 Subject: [PATCH] fix: Unify public-only token filtering in API queries and repo access checks (#37118) This PR closes remaining `public-only` token gaps in the API by making the restriction apply consistently across repository, organization, activity, notification, and authenticated `/api/v1/user/...` routes. Previously, `public-only` tokens were still able to: - receive private results from some list/search/self endpoints, - access repository data through ID-based lookups, - and reach several authenticated self routes that should remain unavailable for public-only access. This change treats `public-only` as a cross-cutting visibility boundary: - list/search endpoints now filter private resources consistently, - repository lookups enforce the same restriction even when addressed indirectly, - and self routes that inherently expose or mutate private account state now reject `public-only` tokens. --- Generated by a coding agent with Codex 5.2 --------- Co-authored-by: silverwind Co-authored-by: Claude (Opus 4.7) --- models/activities/action.go | 6 + models/organization/org_list.go | 6 + models/repo/repo_list.go | 7 + models/repo/user_repo.go | 12 ++ models/user/search.go | 6 + routers/api/v1/api.go | 139 ++++++++------ routers/api/v1/org/org.go | 11 +- routers/api/v1/repo/issue.go | 3 +- routers/api/v1/repo/repo.go | 9 +- routers/api/v1/user/repo.go | 11 +- routers/api/v1/user/star.go | 7 +- routers/api/v1/user/user.go | 13 +- routers/api/v1/user/watch.go | 7 +- services/context/api.go | 7 + tests/integration/api_issue_test.go | 2 +- tests/integration/api_notification_test.go | 20 ++ tests/integration/api_public_only_test.go | 107 +++++++++++ tests/integration/api_repo_branch_test.go | 10 +- tests/integration/api_user_orgs_test.go | 41 ++++ .../integration/api_user_public_only_test.go | 177 ++++++++++++++++++ tests/integration/api_user_star_test.go | 22 +++ tests/integration/api_user_watch_test.go | 25 +++ 22 files changed, 561 insertions(+), 87 deletions(-) create mode 100644 tests/integration/api_public_only_test.go create mode 100644 tests/integration/api_user_public_only_test.go diff --git a/models/activities/action.go b/models/activities/action.go index 4ffdca842a..97388402d4 100644 --- a/models/activities/action.go +++ b/models/activities/action.go @@ -436,6 +436,12 @@ type GetFeedsOptions struct { DontCount bool // do counting in GetFeeds } +func (opts *GetFeedsOptions) ApplyPublicOnly(publicOnly bool) { + if publicOnly { + opts.IncludePrivate = false + } +} + // ActivityReadable return whether doer can read activities of user func ActivityReadable(user, doer *user_model.User) bool { return !user.KeepActivityPrivate || diff --git a/models/organization/org_list.go b/models/organization/org_list.go index f37961b5f6..136417d932 100644 --- a/models/organization/org_list.go +++ b/models/organization/org_list.go @@ -54,6 +54,12 @@ type FindOrgOptions struct { IncludeVisibility structs.VisibleType } +func (opts *FindOrgOptions) ApplyPublicOnly(publicOnly bool) { + if publicOnly { + opts.IncludeVisibility = structs.VisibleTypePublic + } +} + func queryUserOrgIDs(userID int64, includePrivate bool) *builder.Builder { cond := builder.Eq{"uid": userID} if !includePrivate { diff --git a/models/repo/repo_list.go b/models/repo/repo_list.go index e927174a55..25dee1bf0e 100644 --- a/models/repo/repo_list.go +++ b/models/repo/repo_list.go @@ -212,6 +212,13 @@ type SearchRepoOptions struct { OnlyShowRelevant bool } +func (opts *SearchRepoOptions) ApplyPublicOnly(publicOnly bool) { + if publicOnly { + opts.Private = false + opts.AllLimited = false + } +} + // UserOwnedRepoCond returns user ownered repositories func UserOwnedRepoCond(userID int64) builder.Cond { return builder.Eq{ diff --git a/models/repo/user_repo.go b/models/repo/user_repo.go index 28ae83a095..e4b0a1b067 100644 --- a/models/repo/user_repo.go +++ b/models/repo/user_repo.go @@ -24,6 +24,12 @@ type StarredReposOptions struct { IncludePrivate bool } +func (opts *StarredReposOptions) ApplyPublicOnly(publicOnly bool) { + if publicOnly { + opts.IncludePrivate = false + } +} + func (opts *StarredReposOptions) ToConds() builder.Cond { var cond builder.Cond = builder.Eq{ "star.uid": opts.StarrerID, @@ -62,6 +68,12 @@ type WatchedReposOptions struct { IncludePrivate bool } +func (opts *WatchedReposOptions) ApplyPublicOnly(publicOnly bool) { + if publicOnly { + opts.IncludePrivate = false + } +} + func (opts *WatchedReposOptions) ToConds() builder.Cond { var cond builder.Cond = builder.Eq{ "watch.user_id": opts.WatcherID, diff --git a/models/user/search.go b/models/user/search.go index 36551b1913..ac9cb8ff99 100644 --- a/models/user/search.go +++ b/models/user/search.go @@ -58,6 +58,12 @@ type SearchUserOptions struct { IncludeReserved bool } +func (opts *SearchUserOptions) ApplyPublicOnly(publicOnly bool) { + if publicOnly { + opts.Visible = []structs.VisibleType{structs.VisibleTypePublic} + } +} + func (opts *SearchUserOptions) toSearchQueryBase(ctx context.Context) db.Session { var cond builder.Cond cond = builder.In("type", opts.Types) diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index a8bfa0965e..e84b07ba47 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -212,6 +212,11 @@ func repoAssignment() func(ctx *context.APIContext) { ctx.APIErrorNotFound() return } + + if !ctx.TokenCanAccessRepo(repo) { + ctx.APIErrorNotFound() + return + } } } @@ -249,51 +254,66 @@ func checkTokenPublicOnly() func(ctx *context.APIContext) { return } - // public Only permission check - switch { - case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryRepository): - if ctx.Repo.Repository != nil && ctx.Repo.Repository.IsPrivate { - ctx.APIError(http.StatusForbidden, "token scope is limited to public repos") - return - } - case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryIssue): - if ctx.Repo.Repository != nil && ctx.Repo.Repository.IsPrivate { - ctx.APIError(http.StatusForbidden, "token scope is limited to public issues") - return - } - case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryOrganization): - if ctx.Org.Organization != nil && ctx.Org.Organization.Visibility != api.VisibleTypePublic { - ctx.APIError(http.StatusForbidden, "token scope is limited to public orgs") - return - } - if ctx.ContextUser != nil && ctx.ContextUser.IsOrganization() && ctx.ContextUser.Visibility != api.VisibleTypePublic { - ctx.APIError(http.StatusForbidden, "token scope is limited to public orgs") - return - } - case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryUser): - if ctx.ContextUser != nil && ctx.ContextUser.IsTokenAccessAllowed() && ctx.ContextUser.Visibility != api.VisibleTypePublic { - ctx.APIError(http.StatusForbidden, "token scope is limited to public users") - return - } - case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryActivityPub): - if ctx.ContextUser != nil && ctx.ContextUser.IsTokenAccessAllowed() && ctx.ContextUser.Visibility != api.VisibleTypePublic { - ctx.APIError(http.StatusForbidden, "token scope is limited to public activitypub") - return - } - case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryNotification): - if ctx.Repo.Repository != nil && ctx.Repo.Repository.IsPrivate { - ctx.APIError(http.StatusForbidden, "token scope is limited to public notifications") - return - } - case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryPackage): - if ctx.Package != nil && ctx.Package.Owner.Visibility.IsPrivate() { - ctx.APIError(http.StatusForbidden, "token scope is limited to public packages") - return + for _, category := range requiredScopeCategories { + switch category { + case auth_model.AccessTokenScopeCategoryRepository: + if !ctx.TokenCanAccessRepo(ctx.Repo.Repository) { + ctx.APIError(http.StatusForbidden, "token scope is limited to public repos") + return + } + case auth_model.AccessTokenScopeCategoryIssue: + if !ctx.TokenCanAccessRepo(ctx.Repo.Repository) { + ctx.APIError(http.StatusForbidden, "token scope is limited to public issues") + return + } + case auth_model.AccessTokenScopeCategoryOrganization: + orgPrivate := ctx.Org.Organization != nil && !ctx.Org.Organization.Visibility.IsPublic() + userOrgPrivate := ctx.ContextUser != nil && ctx.ContextUser.IsOrganization() && !ctx.ContextUser.Visibility.IsPublic() + if orgPrivate || userOrgPrivate { + ctx.APIError(http.StatusForbidden, "token scope is limited to public orgs") + return + } + case auth_model.AccessTokenScopeCategoryUser: + if ctx.ContextUser != nil && ctx.ContextUser.IsTokenAccessAllowed() && !ctx.ContextUser.Visibility.IsPublic() { + ctx.APIError(http.StatusForbidden, "token scope is limited to public users") + return + } + case auth_model.AccessTokenScopeCategoryActivityPub: + if ctx.ContextUser != nil && ctx.ContextUser.IsTokenAccessAllowed() && !ctx.ContextUser.Visibility.IsPublic() { + ctx.APIError(http.StatusForbidden, "token scope is limited to public activitypub") + return + } + case auth_model.AccessTokenScopeCategoryNotification: + if !ctx.TokenCanAccessRepo(ctx.Repo.Repository) { + ctx.APIError(http.StatusForbidden, "token scope is limited to public notifications") + return + } + case auth_model.AccessTokenScopeCategoryPackage: + if ctx.Package != nil && ctx.Package.Owner.Visibility.IsPrivate() { + ctx.APIError(http.StatusForbidden, "token scope is limited to public packages") + return + } } } } } +func rejectPublicOnly() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + if !ctx.PublicOnly { + return + } + + ctx.APIError(http.StatusForbidden, "this endpoint is not available for public-only tokens") + } +} + +func contextAuthenticatedUser() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + ctx.ContextUser = ctx.Doer + } +} + // if a token is being used for auth, we check that it contains the required scope // if a token is not being used, reqToken will enforce other sign in methods func tokenRequiresScopes(requiredScopeCategories ...auth_model.AccessTokenScopeCategory) func(ctx *context.APIContext) { @@ -957,6 +977,8 @@ func Routes() *web.Router { }) // Notifications (requires 'notifications' scope) + // The notifications API is not available for public-only tokens because a user's notifications mix + // public and private repository events in the same mailbox. m.Group("/notifications", func() { m.Combo(""). Get(reqToken(), notify.ListNotifications). @@ -965,7 +987,7 @@ func Routes() *web.Router { m.Combo("/threads/{id}"). Get(reqToken(), notify.GetThread). Patch(reqToken(), notify.ReadThread) - }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryNotification)) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryNotification), rejectPublicOnly()) // Users (requires user scope) m.Group("/users", func() { @@ -1013,8 +1035,9 @@ func Routes() *web.Router { m.Group("/settings", func() { m.Get("", user.GetUserSettings) m.Patch("", bind(api.UserSettingsOptions{}), user.UpdateUserSettings) - }, reqToken()) - m.Combo("/emails"). + }, rejectPublicOnly()) + // Email addresses are always private account data. + m.Combo("/emails", rejectPublicOnly()). Get(user.ListEmails). Post(bind(api.CreateEmailOption{}), user.AddEmail). Delete(bind(api.DeleteEmailOption{}), user.DeleteEmail) @@ -1046,7 +1069,7 @@ func Routes() *web.Router { m.Get("/runs", reqToken(), user.ListWorkflowRuns) m.Get("/jobs", reqToken(), user.ListWorkflowJobs) - }) + }, rejectPublicOnly()) m.Get("/followers", user.ListMyFollowers) m.Group("/following", func() { @@ -1064,7 +1087,7 @@ func Routes() *web.Router { Post(bind(api.CreateKeyOption{}), user.CreatePublicKey) m.Combo("/{id}").Get(user.GetPublicKey). Delete(user.DeletePublicKey) - }) + }, rejectPublicOnly()) // (admin:application scope) m.Group("/applications", func() { @@ -1075,7 +1098,7 @@ func Routes() *web.Router { Delete(user.DeleteOauth2Application). Patch(bind(api.CreateOAuth2ApplicationOptions{}), user.UpdateOauth2Application). Get(user.GetOauth2Application) - }) + }, rejectPublicOnly()) // (admin:gpg_key scope) m.Group("/gpg_keys", func() { @@ -1083,13 +1106,13 @@ func Routes() *web.Router { Post(bind(api.CreateGPGKeyOption{}), user.CreateGPGKey) m.Combo("/{id}").Get(user.GetGPGKey). Delete(user.DeleteGPGKey) - }) - m.Get("/gpg_key_token", user.GetVerificationToken) - m.Post("/gpg_key_verify", bind(api.VerifyGPGKeyOption{}), user.VerifyUserGPGKey) + }, rejectPublicOnly()) + m.Get("/gpg_key_token", rejectPublicOnly(), user.GetVerificationToken) + m.Post("/gpg_key_verify", rejectPublicOnly(), bind(api.VerifyGPGKeyOption{}), user.VerifyUserGPGKey) // (repo scope) m.Combo("/repos", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)).Get(user.ListMyRepos). - Post(bind(api.CreateRepoOption{}), repo.Create) + Post(rejectPublicOnly(), bind(api.CreateRepoOption{}), repo.Create) // (repo scope) m.Group("/starred", func() { @@ -1100,22 +1123,22 @@ func Routes() *web.Router { m.Delete("", user.Unstar) }, repoAssignment(), checkTokenPublicOnly()) }, reqStarsEnabled(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)) - m.Get("/times", repo.ListMyTrackedTimes) - m.Get("/stopwatches", repo.GetStopwatches) + m.Get("/times", rejectPublicOnly(), repo.ListMyTrackedTimes) + m.Get("/stopwatches", rejectPublicOnly(), repo.GetStopwatches) m.Get("/subscriptions", user.GetMyWatchedRepos) - m.Get("/teams", org.ListUserTeams) + m.Get("/teams", rejectPublicOnly(), org.ListUserTeams) m.Group("/hooks", func() { m.Combo("").Get(user.ListHooks). Post(bind(api.CreateHookOption{}), user.CreateHook) m.Combo("/{id}").Get(user.GetHook). Patch(bind(api.EditHookOption{}), user.EditHook). Delete(user.DeleteHook) - }, reqWebhooksEnabled()) + }, reqWebhooksEnabled(), rejectPublicOnly()) m.Group("/avatar", func() { m.Post("", bind(api.UpdateUserAvatarOption{}), user.UpdateAvatar) m.Delete("", user.DeleteAvatar) - }) + }, rejectPublicOnly()) m.Group("/blocks", func() { m.Get("", user.ListBlocks) @@ -1124,8 +1147,8 @@ func Routes() *web.Router { m.Put("", user.BlockUser) m.Delete("", user.UnblockUser) }, context.UserAssignmentAPI(), checkTokenPublicOnly()) - }) - }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken()) + }, rejectPublicOnly()) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken(), contextAuthenticatedUser(), checkTokenPublicOnly()) // Repositories (requires repo scope, org scope) m.Post("/org/{org}/repos", @@ -1601,7 +1624,7 @@ func Routes() *web.Router { }, reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryPackage), context.UserAssignmentAPI(), context.PackageAssignmentAPI(), reqPackageAccess(perm.AccessModeRead), checkTokenPublicOnly()) // Organizations - m.Get("/user/orgs", reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), org.ListMyOrgs) + m.Get("/user/orgs", reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), checkTokenPublicOnly(), org.ListMyOrgs) m.Group("/users/{username}/orgs", func() { m.Get("", reqToken(), org.ListUserOrgs) m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions) diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index 2df871a0aa..d34462540d 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -40,6 +40,7 @@ func listUserOrgs(ctx *context.APIContext, u *user_model.User) { UserID: u.ID, IncludeVisibility: organization.DoerViewOtherVisibility(ctx.Doer, u), } + opts.ApplyPublicOnly(ctx.PublicOnly) orgs, maxResults, err := db.FindAndCount[organization.Organization](ctx, opts) if err != nil { ctx.APIErrorInternal(err) @@ -199,7 +200,7 @@ func GetAll(ctx *context.APIContext) { // "$ref": "#/responses/OrganizationList" vMode := []api.VisibleType{api.VisibleTypePublic} - if ctx.IsSigned && !ctx.PublicOnly { + if ctx.IsSigned { vMode = append(vMode, api.VisibleTypeLimited) if ctx.Doer.IsAdmin { vMode = append(vMode, api.VisibleTypePrivate) @@ -208,13 +209,16 @@ func GetAll(ctx *context.APIContext) { listOptions := utils.GetListOptions(ctx) - publicOrgs, maxResults, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{ + searchOpts := user_model.SearchUserOptions{ Actor: ctx.Doer, ListOptions: listOptions, Types: []user_model.UserType{user_model.UserTypeOrganization}, OrderBy: db.SearchOrderByAlphabetically, Visible: vMode, - }) + } + searchOpts.ApplyPublicOnly(ctx.PublicOnly) + + publicOrgs, maxResults, err := user_model.SearchUsers(ctx, searchOpts) if err != nil { ctx.APIErrorInternal(err) return @@ -494,6 +498,7 @@ func ListOrgActivityFeeds(ctx *context.APIContext) { Date: ctx.FormString("date"), ListOptions: listOptions, } + opts.ApplyPublicOnly(ctx.PublicOnly) feeds, count, err := feed_service.GetFeeds(ctx, opts) if err != nil { diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index 39ca7fb77e..64692e0c22 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -47,9 +47,10 @@ func buildSearchIssuesRepoIDs(ctx *context.APIContext) (repoIDs []int64, allPubl Actor: ctx.Doer, } if ctx.IsSigned { - opts.Private = !ctx.PublicOnly + opts.Private = true opts.AllLimited = true } + opts.ApplyPublicOnly(ctx.PublicOnly) if ctx.FormString("owner") != "" { owner, err := user_model.GetUserByName(ctx, ctx.FormString("owner")) if err != nil { diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index 12e525464c..83c5b02e49 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -134,9 +134,6 @@ func Search(ctx *context.APIContext) { // "$ref": "#/responses/validationError" private := ctx.IsSigned && (ctx.FormString("private") == "" || ctx.FormBool("private")) - if ctx.PublicOnly { - private = false - } opts := repo_model.SearchRepoOptions{ ListOptions: utils.GetListOptions(ctx), @@ -152,6 +149,7 @@ func Search(ctx *context.APIContext) { StarredByID: ctx.FormInt64("starredBy"), IncludeDescription: ctx.FormBool("includeDesc"), } + opts.ApplyPublicOnly(ctx.PublicOnly) if ctx.FormString("template") != "" { opts.Template = optional.Some(ctx.FormBool("template")) @@ -557,6 +555,10 @@ func GetByID(ctx *context.APIContext) { } return } + if !ctx.TokenCanAccessRepo(repo) { + ctx.APIErrorNotFound() + return + } permission, err := access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer) if err != nil { @@ -1309,6 +1311,7 @@ func ListRepoActivityFeeds(ctx *context.APIContext) { Date: ctx.FormString("date"), ListOptions: listOptions, } + opts.ApplyPublicOnly(ctx.PublicOnly) feeds, count, err := feed_service.GetFeeds(ctx, opts) if err != nil { diff --git a/routers/api/v1/user/repo.go b/routers/api/v1/user/repo.go index a664888dbf..0e0b6b431b 100644 --- a/routers/api/v1/user/repo.go +++ b/routers/api/v1/user/repo.go @@ -19,12 +19,15 @@ import ( func listUserRepos(ctx *context.APIContext, u *user_model.User, private bool) { opts := utils.GetListOptions(ctx) - repos, count, err := repo_model.GetUserRepositories(ctx, repo_model.SearchRepoOptions{ + searchOpts := repo_model.SearchRepoOptions{ Actor: u, Private: private, ListOptions: opts, OrderBy: "id ASC", - }) + } + searchOpts.ApplyPublicOnly(ctx.PublicOnly) + + repos, count, err := repo_model.GetUserRepositories(ctx, searchOpts) if err != nil { ctx.APIErrorInternal(err) return @@ -79,8 +82,7 @@ func ListUserRepos(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - private := ctx.IsSigned - listUserRepos(ctx, ctx.ContextUser, private) + listUserRepos(ctx, ctx.ContextUser, ctx.IsSigned) } // ListMyRepos - list the repositories you own or have access to. @@ -110,6 +112,7 @@ func ListMyRepos(ctx *context.APIContext) { Private: ctx.IsSigned, IncludeDescription: true, } + opts.ApplyPublicOnly(ctx.PublicOnly) repos, count, err := repo_model.SearchRepository(ctx, opts) if err != nil { diff --git a/routers/api/v1/user/star.go b/routers/api/v1/user/star.go index 50a54b2683..fb2a324eed 100644 --- a/routers/api/v1/user/star.go +++ b/routers/api/v1/user/star.go @@ -20,11 +20,14 @@ import ( // getStarredRepos returns the repos that the user with the specified userID has // starred func getStarredRepos(ctx *context.APIContext, user *user_model.User, private bool) ([]*api.Repository, error) { - starredRepos, err := repo_model.GetStarredRepos(ctx, &repo_model.StarredReposOptions{ + opts := &repo_model.StarredReposOptions{ ListOptions: utils.GetListOptions(ctx), StarrerID: user.ID, IncludePrivate: private, - }) + } + opts.ApplyPublicOnly(ctx.PublicOnly) + + starredRepos, err := repo_model.GetStarredRepos(ctx, opts) if err != nil { return nil, err } diff --git a/routers/api/v1/user/user.go b/routers/api/v1/user/user.go index 005770c571..539e6d7f6a 100644 --- a/routers/api/v1/user/user.go +++ b/routers/api/v1/user/user.go @@ -9,7 +9,6 @@ import ( activities_model "code.gitea.io/gitea/models/activities" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/routers/api/v1/utils" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" @@ -69,19 +68,16 @@ func Search(ctx *context.APIContext) { maxResults = 1 users = []*user_model.User{user_model.NewActionsUser()} default: - var visible []structs.VisibleType - if ctx.PublicOnly { - visible = []structs.VisibleType{structs.VisibleTypePublic} - } - users, maxResults, err = user_model.SearchUsers(ctx, user_model.SearchUserOptions{ + opts := user_model.SearchUserOptions{ Actor: ctx.Doer, Keyword: ctx.FormTrim("q"), UID: uid, Types: []user_model.UserType{user_model.UserTypeIndividual}, SearchByEmail: true, - Visible: visible, ListOptions: listOptions, - }) + } + opts.ApplyPublicOnly(ctx.PublicOnly) + users, maxResults, err = user_model.SearchUsers(ctx, opts) if err != nil { ctx.JSON(http.StatusInternalServerError, map[string]any{ "ok": false, @@ -214,6 +210,7 @@ func ListUserActivityFeeds(ctx *context.APIContext) { Date: ctx.FormString("date"), ListOptions: listOptions, } + opts.ApplyPublicOnly(ctx.PublicOnly) feeds, count, err := feed_service.GetFeeds(ctx, opts) if err != nil { diff --git a/routers/api/v1/user/watch.go b/routers/api/v1/user/watch.go index 9c11d5ca35..1773193f06 100644 --- a/routers/api/v1/user/watch.go +++ b/routers/api/v1/user/watch.go @@ -18,11 +18,14 @@ import ( // getWatchedRepos returns the repos that the user with the specified userID is watching func getWatchedRepos(ctx *context.APIContext, user *user_model.User, private bool) ([]*api.Repository, int64, error) { - watchedRepos, total, err := repo_model.GetWatchedRepos(ctx, &repo_model.WatchedReposOptions{ + opts := &repo_model.WatchedReposOptions{ ListOptions: utils.GetListOptions(ctx), WatcherID: user.ID, IncludePrivate: private, - }) + } + opts.ApplyPublicOnly(ctx.PublicOnly) + + watchedRepos, total, err := repo_model.GetWatchedRepos(ctx, opts) if err != nil { return nil, 0, err } diff --git a/services/context/api.go b/services/context/api.go index 3f9f3e1cdd..5b3a35a7fa 100644 --- a/services/context/api.go +++ b/services/context/api.go @@ -13,6 +13,7 @@ import ( "strconv" "strings" + repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/cache" @@ -47,6 +48,12 @@ type APIContext struct { PublicOnly bool // Whether the request is for a public endpoint } +// TokenCanAccessRepo reports whether the current API token is allowed to access the repository. +// A public-only token cannot reach a private repo; any other token is unrestricted by this check. +func (ctx *APIContext) TokenCanAccessRepo(repo *repo_model.Repository) bool { + return repo == nil || !ctx.PublicOnly || !repo.IsPrivate +} + func init() { web.RegisterResponseStatusProvider[*APIContext](func(req *http.Request) web_types.ResponseStatusProvider { return req.Context().Value(apiContextKey).(*APIContext) diff --git a/tests/integration/api_issue_test.go b/tests/integration/api_issue_test.go index 8f20814251..b8728ef09f 100644 --- a/tests/integration/api_issue_test.go +++ b/tests/integration/api_issue_test.go @@ -108,7 +108,7 @@ func testAPIListIssuesPublicOnly(t *testing.T) { publicOnlyToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue, auth_model.AccessTokenScopePublicOnly) req = NewRequest(t, "GET", link.String()).AddTokenAuth(publicOnlyToken) - MakeRequest(t, req, http.StatusForbidden) + MakeRequest(t, req, http.StatusNotFound) } func testAPICreateIssue(t *testing.T) { diff --git a/tests/integration/api_notification_test.go b/tests/integration/api_notification_test.go index 275521572d..4b3147ec01 100644 --- a/tests/integration/api_notification_test.go +++ b/tests/integration/api_notification_test.go @@ -209,3 +209,23 @@ func TestAPINotificationPUT(t *testing.T) { assert.True(t, apiNL[0].Unread) assert.False(t, apiNL[0].Pinned) } + +func TestAPINotificationPublicOnly(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + thread5 := unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{ID: 5}) + + token := getUserToken(t, user2.Name, auth_model.AccessTokenScopeReadNotification, auth_model.AccessTokenScopePublicOnly) + req := NewRequest(t, "GET", "/api/v1/notifications"). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + + req = NewRequest(t, "GET", "/api/v1/notifications/new"). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications/threads/%d", thread5.ID)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) +} diff --git a/tests/integration/api_public_only_test.go b/tests/integration/api_public_only_test.go new file mode 100644 index 0000000000..40cda8cbaf --- /dev/null +++ b/tests/integration/api_public_only_test.go @@ -0,0 +1,107 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIUserReposPublicOnly(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadRepository, auth_model.AccessTokenScopePublicOnly) + req := NewRequest(t, "GET", "/api/v1/user/repos"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var repos []api.Repository + DecodeJSON(t, resp, &repos) + assert.NotEmpty(t, repos) + for _, repo := range repos { + assert.False(t, repo.Private) + } + assert.NotContains(t, repoNames(repos), "user2/repo2") + + req = NewRequest(t, "GET", "/api/v1/users/user2/repos"). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &repos) + assert.NotEmpty(t, repos) + for _, repo := range repos { + assert.False(t, repo.Private) + } + assert.NotContains(t, repoNames(repos), "user2/repo2") +} + +func repoNames(repos []api.Repository) []string { + names := make([]string, 0, len(repos)) + for _, repo := range repos { + names = append(names, repo.FullName) + } + return names +} + +func TestAPIRepoByIDPublicOnly(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository, auth_model.AccessTokenScopePublicOnly) + req := NewRequest(t, "GET", "/api/v1/repositories/1"). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", "/api/v1/repositories/2"). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) +} + +func TestAPIActivityFeedsPublicOnly(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser) + req := NewRequest(t, "GET", "/api/v1/users/user2/activities/feeds"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var activities []api.Activity + DecodeJSON(t, resp, &activities) + assert.NotEmpty(t, activities) + + publicToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopePublicOnly) + req = NewRequest(t, "GET", "/api/v1/users/user2/activities/feeds"). + AddTokenAuth(publicToken) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &activities) + assertPublicActivitiesOnly(t, activities) + + orgToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadOrganization) + req = NewRequest(t, "GET", "/api/v1/orgs/org3/activities/feeds"). + AddTokenAuth(orgToken) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &activities) + assert.NotEmpty(t, activities) + + publicOrgToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopePublicOnly) + req = NewRequest(t, "GET", "/api/v1/orgs/org3/activities/feeds"). + AddTokenAuth(publicOrgToken) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &activities) + assertPublicActivitiesOnly(t, activities) +} + +func assertPublicActivitiesOnly(t *testing.T, activities []api.Activity) { + t.Helper() + + for _, activity := range activities { + assert.False(t, activity.IsPrivate) + if activity.Repo != nil { + assert.False(t, activity.Repo.Private) + } + } +} diff --git a/tests/integration/api_repo_branch_test.go b/tests/integration/api_repo_branch_test.go index 2438db72c5..c5822456e8 100644 --- a/tests/integration/api_repo_branch_test.go +++ b/tests/integration/api_repo_branch_test.go @@ -29,10 +29,10 @@ func TestAPIRepoBranchesPlain(t *testing.T) { user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) session := loginUser(t, user1.LowerName) - // public only token should be forbidden + // public-only token cannot see a private repo publicOnlyToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopePublicOnly, auth_model.AccessTokenScopeWriteRepository) link, _ := url.Parse(fmt.Sprintf("/api/v1/repos/org3/%s/branches", repo3.Name)) // a plain repo - MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(publicOnlyToken), http.StatusForbidden) + MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(publicOnlyToken), http.StatusNotFound) token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) resp := MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK) @@ -46,7 +46,7 @@ func TestAPIRepoBranchesPlain(t *testing.T) { assert.Equal(t, "master", branches[1].Name) link2, _ := url.Parse(fmt.Sprintf("/api/v1/repos/org3/%s/branches/test_branch", repo3.Name)) - MakeRequest(t, NewRequest(t, "GET", link2.String()).AddTokenAuth(publicOnlyToken), http.StatusForbidden) + MakeRequest(t, NewRequest(t, "GET", link2.String()).AddTokenAuth(publicOnlyToken), http.StatusNotFound) resp = MakeRequest(t, NewRequest(t, "GET", link2.String()).AddTokenAuth(token), http.StatusOK) bs, err = io.ReadAll(resp.Body) @@ -55,7 +55,7 @@ func TestAPIRepoBranchesPlain(t *testing.T) { assert.NoError(t, json.Unmarshal(bs, &branch)) assert.Equal(t, "test_branch", branch.Name) - MakeRequest(t, NewRequest(t, "POST", link.String()).AddTokenAuth(publicOnlyToken), http.StatusForbidden) + MakeRequest(t, NewRequest(t, "POST", link.String()).AddTokenAuth(publicOnlyToken), http.StatusNotFound) req := NewRequest(t, "POST", link.String()).AddTokenAuth(token) req.Header.Add("Content-Type", "application/json") @@ -81,7 +81,7 @@ func TestAPIRepoBranchesPlain(t *testing.T) { link3, _ := url.Parse(fmt.Sprintf("/api/v1/repos/org3/%s/branches/test_branch2", repo3.Name)) MakeRequest(t, NewRequest(t, "DELETE", link3.String()), http.StatusNotFound) - MakeRequest(t, NewRequest(t, "DELETE", link3.String()).AddTokenAuth(publicOnlyToken), http.StatusForbidden) + MakeRequest(t, NewRequest(t, "DELETE", link3.String()).AddTokenAuth(publicOnlyToken), http.StatusNotFound) MakeRequest(t, NewRequest(t, "DELETE", link3.String()).AddTokenAuth(token), http.StatusNoContent) assert.NoError(t, err) diff --git a/tests/integration/api_user_orgs_test.go b/tests/integration/api_user_orgs_test.go index 3e8e17c28a..30e2578152 100644 --- a/tests/integration/api_user_orgs_test.go +++ b/tests/integration/api_user_orgs_test.go @@ -154,3 +154,44 @@ func TestMyOrgs(t *testing.T) { }, }, orgs) } + +func TestMyOrgsPublicOnly(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + normalUsername := "user2" + token := getUserToken(t, normalUsername, auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopePublicOnly) + req := NewRequest(t, "GET", "/api/v1/user/orgs"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var orgs []*api.Organization + DecodeJSON(t, resp, &orgs) + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "org3"}) + org17 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "org17"}) + + assert.Equal(t, []*api.Organization{ + { + ID: 17, + Name: org17.Name, + UserName: org17.Name, + FullName: org17.FullName, + Email: org17.Email, + AvatarURL: org17.AvatarLink(t.Context()), + Description: "", + Website: "", + Location: "", + Visibility: "public", + }, + { + ID: 3, + Name: org3.Name, + UserName: org3.Name, + FullName: org3.FullName, + Email: org3.Email, + AvatarURL: org3.AvatarLink(t.Context()), + Description: "", + Website: "", + Location: "", + Visibility: "public", + }, + }, orgs) +} diff --git a/tests/integration/api_user_public_only_test.go b/tests/integration/api_user_public_only_test.go new file mode 100644 index 0000000000..5404477cda --- /dev/null +++ b/tests/integration/api_user_public_only_test.go @@ -0,0 +1,177 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/require" +) + +func TestAPIPublicOnlySelfUserRoutes(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + privateUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user31"}) + require.True(t, privateUser.Visibility.IsPrivate()) + + privateSession := loginUser(t, privateUser.Name) + privateReadUserToken := getTokenForLoggedInUser(t, privateSession, + auth_model.AccessTokenScopePublicOnly, + auth_model.AccessTokenScopeReadUser, + ) + privateWriteUserToken := getTokenForLoggedInUser(t, privateSession, + auth_model.AccessTokenScopePublicOnly, + auth_model.AccessTokenScopeWriteUser, + ) + + t.Run("PrivateProfileForbidden", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + MakeRequest(t, NewRequest(t, "GET", "/api/v1/users/user31").AddTokenAuth(privateReadUserToken), http.StatusForbidden) + MakeRequest(t, NewRequest(t, "GET", "/api/v1/user").AddTokenAuth(privateReadUserToken), http.StatusForbidden) + }) + + t.Run("PrivateSensitiveSelfRoutesForbidden", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + MakeRequest(t, NewRequest(t, "GET", "/api/v1/user/settings").AddTokenAuth(privateReadUserToken), http.StatusForbidden) + hideEmail := true + settingsReq := NewRequestWithJSON(t, "PATCH", "/api/v1/user/settings", &api.UserSettingsOptions{ + HideEmail: &hideEmail, + }).AddTokenAuth(privateWriteUserToken) + MakeRequest(t, settingsReq, http.StatusForbidden) + + MakeRequest(t, NewRequest(t, "GET", "/api/v1/user/emails").AddTokenAuth(privateReadUserToken), http.StatusForbidden) + emailReq := NewRequestWithJSON(t, "POST", "/api/v1/user/emails", &api.CreateEmailOption{ + Emails: []string{"user31-public-only@example.com"}, + }).AddTokenAuth(privateWriteUserToken) + MakeRequest(t, emailReq, http.StatusForbidden) + + keyReq := NewRequestWithJSON(t, "POST", "/api/v1/user/keys", api.CreateKeyOption{ + Title: "public-only-private-key", + Key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4cn+iXnA4KvcQYSV88vGn0Yi91vG47t1P7okprVmhNTkipNRIHWr6WdCO4VDr/cvsRkuVJAsLO2enwjGWWueOO6BodiBgyAOZ/5t5nJNMCNuLGT5UIo/RI1b0WRQwxEZTRjt6mFNw6lH14wRd8ulsr9toSWBPMOGWoYs1PDeDL0JuTjL+tr1SZi/EyxCngpYszKdXllJEHyI79KQgeD0Vt3pTrkbNVTOEcCNqZePSVmUH8X8Vhugz3bnE0/iE9Pb5fkWO9c4AnM1FgI/8Bvp27Fw2ShryIXuR6kKvUqhVMTuOSDHwu6A8jLE5Owt3GAYugDpDYuwTVNGrHLXKpPzrGGPE/jPmaLCMZcsdkec95dYeU3zKODEm8UQZFhmJmDeWVJ36nGrGZHL4J5aTTaeFUJmmXDaJYiJ+K2/ioKgXqnXvltu0A9R8/LGy4nrTJRr4JMLuJFoUXvGm1gXQ70w2LSpk6yl71RNC0hCtsBe8BP8IhYCM0EP5jh7eCMQZNvM= nocomment", + }).AddTokenAuth(privateWriteUserToken) + MakeRequest(t, keyReq, http.StatusForbidden) + + oauthReq := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &api.CreateOAuth2ApplicationOptions{ + Name: "public-only-private-oauth-app", + RedirectURIs: []string{"https://example.com/callback"}, + ConfidentialClient: true, + }).AddTokenAuth(privateWriteUserToken) + MakeRequest(t, oauthReq, http.StatusForbidden) + + MakeRequest(t, NewRequest(t, "GET", "/api/v1/user/gpg_keys").AddTokenAuth(privateReadUserToken), http.StatusForbidden) + gpgKeyReq := NewRequestWithJSON(t, "POST", "/api/v1/user/gpg_keys", &api.CreateGPGKeyOption{ + ArmoredKey: "-----BEGIN PGP PUBLIC KEY BLOCK-----\ncomment\n-----END PGP PUBLIC KEY BLOCK-----", + }).AddTokenAuth(privateWriteUserToken) + MakeRequest(t, gpgKeyReq, http.StatusForbidden) + MakeRequest(t, NewRequest(t, "GET", "/api/v1/user/gpg_key_token").AddTokenAuth(privateReadUserToken), http.StatusForbidden) + gpgVerifyReq := NewRequestWithJSON(t, "POST", "/api/v1/user/gpg_key_verify", &api.VerifyGPGKeyOption{ + KeyID: "deadbeef", + Signature: "invalid-signature", + }).AddTokenAuth(privateWriteUserToken) + MakeRequest(t, gpgVerifyReq, http.StatusForbidden) + + MakeRequest(t, NewRequest(t, "GET", "/api/v1/user/actions/variables").AddTokenAuth(privateReadUserToken), http.StatusForbidden) + MakeRequest(t, NewRequest(t, "DELETE", "/api/v1/user/actions/secrets/PRIVATE_SECRET").AddTokenAuth(privateWriteUserToken), http.StatusForbidden) + variableReq := NewRequestWithJSON(t, "POST", "/api/v1/user/actions/variables/PRIVATE_VAR", api.CreateVariableOption{ + Value: "private-value", + Description: "must stay private", + }).AddTokenAuth(privateWriteUserToken) + MakeRequest(t, variableReq, http.StatusForbidden) + + MakeRequest(t, NewRequest(t, "POST", "/api/v1/user/actions/runners/registration-token").AddTokenAuth(privateWriteUserToken), http.StatusForbidden) + + MakeRequest(t, NewRequest(t, "GET", "/api/v1/user/hooks").AddTokenAuth(privateReadUserToken), http.StatusForbidden) + hookReq := NewRequestWithJSON(t, "POST", "/api/v1/user/hooks", api.CreateHookOption{ + Type: "gitea", + Config: api.CreateHookOptionConfig{ + "content_type": "json", + "url": "http://example.com/", + }, + Name: "public-only-private-hook", + }).AddTokenAuth(privateWriteUserToken) + MakeRequest(t, hookReq, http.StatusForbidden) + + avatarReq := NewRequestWithJSON(t, "POST", "/api/v1/user/avatar", &api.UpdateUserAvatarOption{ + Image: "aGVsbG8=", + }).AddTokenAuth(privateWriteUserToken) + MakeRequest(t, avatarReq, http.StatusForbidden) + MakeRequest(t, NewRequest(t, "DELETE", "/api/v1/user/avatar").AddTokenAuth(privateWriteUserToken), http.StatusForbidden) + MakeRequest(t, NewRequest(t, "GET", "/api/v1/user/times").AddTokenAuth(privateReadUserToken), http.StatusForbidden) + MakeRequest(t, NewRequest(t, "GET", "/api/v1/user/stopwatches").AddTokenAuth(privateReadUserToken), http.StatusForbidden) + + MakeRequest(t, NewRequest(t, "GET", "/api/v1/user/subscriptions").AddTokenAuth(privateReadUserToken), http.StatusForbidden) + MakeRequest(t, NewRequest(t, "GET", "/api/v1/user/teams").AddTokenAuth(privateReadUserToken), http.StatusForbidden) + + MakeRequest(t, NewRequest(t, "GET", "/api/v1/user/blocks").AddTokenAuth(privateReadUserToken), http.StatusForbidden) + MakeRequest(t, NewRequest(t, "PUT", "/api/v1/user/blocks/user2").AddTokenAuth(privateWriteUserToken), http.StatusForbidden) + + MakeRequest(t, NewRequest(t, "PUT", "/api/v1/user/following/user2").AddTokenAuth(privateWriteUserToken), http.StatusForbidden) + MakeRequest(t, NewRequest(t, "DELETE", "/api/v1/user/following/user2").AddTokenAuth(privateWriteUserToken), http.StatusForbidden) + }) + + t.Run("PublicRepoRoutesFilterAndRejectMutations", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + publicSession := loginUser(t, "user2") + fullWriteRepoToken := getTokenForLoggedInUser(t, publicSession, + auth_model.AccessTokenScopeWriteUser, + auth_model.AccessTokenScopeWriteRepository, + ) + publicOnlyReadRepoToken := getTokenForLoggedInUser(t, publicSession, + auth_model.AccessTokenScopePublicOnly, + auth_model.AccessTokenScopeReadUser, + auth_model.AccessTokenScopeReadRepository, + ) + publicOnlyWriteRepoToken := getTokenForLoggedInUser(t, publicSession, + auth_model.AccessTokenScopePublicOnly, + auth_model.AccessTokenScopeWriteUser, + auth_model.AccessTokenScopeWriteRepository, + ) + + publicRepoName := "public-only-visible-self-repo" + privateRepoName := "public-only-hidden-self-repo" + + resp := MakeRequest(t, NewRequestWithJSON(t, "POST", "/api/v1/user/repos", &api.CreateRepoOption{ + Name: publicRepoName, + Private: false, + }).AddTokenAuth(fullWriteRepoToken), http.StatusCreated) + publicRepo := DecodeJSON(t, resp, &api.Repository{}) + require.Equal(t, "user2/"+publicRepoName, publicRepo.FullName) + + resp = MakeRequest(t, NewRequestWithJSON(t, "POST", "/api/v1/user/repos", &api.CreateRepoOption{ + Name: privateRepoName, + Private: true, + }).AddTokenAuth(fullWriteRepoToken), http.StatusCreated) + privateRepo := DecodeJSON(t, resp, &api.Repository{}) + require.Equal(t, "user2/"+privateRepoName, privateRepo.FullName) + + MakeRequest(t, NewRequest(t, "GET", "/api/v1/repos/user2/"+privateRepoName).AddTokenAuth(publicOnlyReadRepoToken), http.StatusNotFound) + + resp = MakeRequest(t, NewRequest(t, "GET", "/api/v1/user/repos").AddTokenAuth(publicOnlyReadRepoToken), http.StatusOK) + repos := DecodeJSON(t, resp, []api.Repository{}) + + foundPublicRepo := false + for _, repo := range repos { + require.NotEqual(t, privateRepo.FullName, repo.FullName) + if repo.FullName == publicRepo.FullName { + foundPublicRepo = true + } + } + require.True(t, foundPublicRepo) + + MakeRequest(t, NewRequestWithJSON(t, "POST", "/api/v1/user/repos", &api.CreateRepoOption{ + Name: "public-only-rejected-self-repo", + Private: false, + }).AddTokenAuth(publicOnlyWriteRepoToken), http.StatusForbidden) + }) +} diff --git a/tests/integration/api_user_star_test.go b/tests/integration/api_user_star_test.go index 65422a4f98..f6b43da4c4 100644 --- a/tests/integration/api_user_star_test.go +++ b/tests/integration/api_user_star_test.go @@ -17,6 +17,7 @@ import ( "code.gitea.io/gitea/tests" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestAPIStar(t *testing.T) { @@ -153,3 +154,24 @@ func TestAPIStarDisabled(t *testing.T) { MakeRequest(t, req, http.StatusForbidden) }) } + +func TestAPIStarPublicOnly(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadRepository, auth_model.AccessTokenScopePublicOnly) + req := NewRequest(t, "GET", "/api/v1/user/starred"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + repos := DecodeJSON(t, resp, []api.Repository{}) + if assert.Len(t, repos, 1) { + assert.Equal(t, "user5/repo4", repos[0].FullName) + } + + req = NewRequest(t, "GET", "/api/v1/users/user2/starred"). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + repos = DecodeJSON(t, resp, []api.Repository{}) + require.Len(t, repos, 1) + assert.Equal(t, "user5/repo4", repos[0].FullName) +} diff --git a/tests/integration/api_user_watch_test.go b/tests/integration/api_user_watch_test.go index 002b8a10e6..6916c5aa59 100644 --- a/tests/integration/api_user_watch_test.go +++ b/tests/integration/api_user_watch_test.go @@ -92,3 +92,28 @@ func TestAPIWatch(t *testing.T) { MakeRequest(t, req, http.StatusNoContent) }) } + +func TestAPIWatchPublicOnly(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user1") + writeRepoToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeReadUser) + publicOnlyToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopePublicOnly, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadRepository) + + MakeRequest(t, NewRequest(t, "PUT", "/api/v1/repos/user2/repo1/subscription").AddTokenAuth(writeRepoToken), http.StatusOK) + MakeRequest(t, NewRequest(t, "PUT", "/api/v1/repos/user2/repo2/subscription").AddTokenAuth(writeRepoToken), http.StatusOK) + + resp := MakeRequest(t, NewRequest(t, "GET", "/api/v1/user/subscriptions").AddTokenAuth(publicOnlyToken), http.StatusOK) + repos := DecodeJSON(t, resp, []api.Repository{}) + for _, r := range repos { + assert.False(t, r.Private, "private repo %s leaked via /user/subscriptions", r.FullName) + } + assert.NotContains(t, repoNames(repos), "user2/repo2") + + resp = MakeRequest(t, NewRequest(t, "GET", "/api/v1/users/user1/subscriptions").AddTokenAuth(publicOnlyToken), http.StatusOK) + repos = DecodeJSON(t, resp, []api.Repository{}) + for _, r := range repos { + assert.False(t, r.Private, "private repo %s leaked via /users/{username}/subscriptions", r.FullName) + } + assert.NotContains(t, repoNames(repos), "user2/repo2") +}