0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-05-24 06:13:13 +02:00

fix: Unify public-only token filtering in API queries and repo access checks (#37118) (#37773)

backport #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 <me@silverwind.io>
Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
Co-authored-by: Nicolas <bircni@icloud.com>
This commit is contained in:
Lunny Xiao 2026-05-19 08:38:51 -07:00 committed by GitHub
parent 6d2b02dac1
commit a34eac5ef4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 561 additions and 87 deletions

View File

@ -436,6 +436,12 @@ type GetFeedsOptions struct {
DontCount bool // do counting in GetFeeds 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 // ActivityReadable return whether doer can read activities of user
func ActivityReadable(user, doer *user_model.User) bool { func ActivityReadable(user, doer *user_model.User) bool {
return !user.KeepActivityPrivate || return !user.KeepActivityPrivate ||

View File

@ -54,6 +54,12 @@ type FindOrgOptions struct {
IncludeVisibility structs.VisibleType IncludeVisibility structs.VisibleType
} }
func (opts *FindOrgOptions) ApplyPublicOnly(publicOnly bool) {
if publicOnly {
opts.IncludeVisibility = structs.VisibleTypePublic
}
}
func queryUserOrgIDs(userID int64, includePrivate bool) *builder.Builder { func queryUserOrgIDs(userID int64, includePrivate bool) *builder.Builder {
cond := builder.Eq{"uid": userID} cond := builder.Eq{"uid": userID}
if !includePrivate { if !includePrivate {

View File

@ -212,6 +212,13 @@ type SearchRepoOptions struct {
OnlyShowRelevant bool OnlyShowRelevant bool
} }
func (opts *SearchRepoOptions) ApplyPublicOnly(publicOnly bool) {
if publicOnly {
opts.Private = false
opts.AllLimited = false
}
}
// UserOwnedRepoCond returns user ownered repositories // UserOwnedRepoCond returns user ownered repositories
func UserOwnedRepoCond(userID int64) builder.Cond { func UserOwnedRepoCond(userID int64) builder.Cond {
return builder.Eq{ return builder.Eq{

View File

@ -24,6 +24,12 @@ type StarredReposOptions struct {
IncludePrivate bool IncludePrivate bool
} }
func (opts *StarredReposOptions) ApplyPublicOnly(publicOnly bool) {
if publicOnly {
opts.IncludePrivate = false
}
}
func (opts *StarredReposOptions) ToConds() builder.Cond { func (opts *StarredReposOptions) ToConds() builder.Cond {
var cond builder.Cond = builder.Eq{ var cond builder.Cond = builder.Eq{
"star.uid": opts.StarrerID, "star.uid": opts.StarrerID,
@ -62,6 +68,12 @@ type WatchedReposOptions struct {
IncludePrivate bool IncludePrivate bool
} }
func (opts *WatchedReposOptions) ApplyPublicOnly(publicOnly bool) {
if publicOnly {
opts.IncludePrivate = false
}
}
func (opts *WatchedReposOptions) ToConds() builder.Cond { func (opts *WatchedReposOptions) ToConds() builder.Cond {
var cond builder.Cond = builder.Eq{ var cond builder.Cond = builder.Eq{
"watch.user_id": opts.WatcherID, "watch.user_id": opts.WatcherID,

View File

@ -59,6 +59,12 @@ type SearchUserOptions struct {
IncludeReserved bool IncludeReserved bool
} }
func (opts *SearchUserOptions) ApplyPublicOnly(publicOnly bool) {
if publicOnly {
opts.Visible = []structs.VisibleType{structs.VisibleTypePublic}
}
}
func (opts *SearchUserOptions) toSearchQueryBase(ctx context.Context) *xorm.Session { func (opts *SearchUserOptions) toSearchQueryBase(ctx context.Context) *xorm.Session {
var cond builder.Cond var cond builder.Cond
cond = builder.In("type", opts.Types) cond = builder.In("type", opts.Types)

View File

@ -212,6 +212,11 @@ func repoAssignment() func(ctx *context.APIContext) {
ctx.APIErrorNotFound() ctx.APIErrorNotFound()
return return
} }
if !ctx.TokenCanAccessRepo(repo) {
ctx.APIErrorNotFound()
return
}
} }
} }
@ -249,51 +254,66 @@ func checkTokenPublicOnly() func(ctx *context.APIContext) {
return return
} }
// public Only permission check for _, category := range requiredScopeCategories {
switch { switch category {
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryRepository): case auth_model.AccessTokenScopeCategoryRepository:
if ctx.Repo.Repository != nil && ctx.Repo.Repository.IsPrivate { if !ctx.TokenCanAccessRepo(ctx.Repo.Repository) {
ctx.APIError(http.StatusForbidden, "token scope is limited to public repos") ctx.APIError(http.StatusForbidden, "token scope is limited to public repos")
return return
} }
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryIssue): case auth_model.AccessTokenScopeCategoryIssue:
if ctx.Repo.Repository != nil && ctx.Repo.Repository.IsPrivate { if !ctx.TokenCanAccessRepo(ctx.Repo.Repository) {
ctx.APIError(http.StatusForbidden, "token scope is limited to public issues") ctx.APIError(http.StatusForbidden, "token scope is limited to public issues")
return return
} }
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryOrganization): case auth_model.AccessTokenScopeCategoryOrganization:
if ctx.Org.Organization != nil && ctx.Org.Organization.Visibility != api.VisibleTypePublic { orgPrivate := ctx.Org.Organization != nil && !ctx.Org.Organization.Visibility.IsPublic()
ctx.APIError(http.StatusForbidden, "token scope is limited to public orgs") userOrgPrivate := ctx.ContextUser != nil && ctx.ContextUser.IsOrganization() && !ctx.ContextUser.Visibility.IsPublic()
return if orgPrivate || userOrgPrivate {
} ctx.APIError(http.StatusForbidden, "token scope is limited to public orgs")
if ctx.ContextUser != nil && ctx.ContextUser.IsOrganization() && ctx.ContextUser.Visibility != api.VisibleTypePublic { return
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() {
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryUser): ctx.APIError(http.StatusForbidden, "token scope is limited to public users")
if ctx.ContextUser != nil && ctx.ContextUser.IsTokenAccessAllowed() && ctx.ContextUser.Visibility != api.VisibleTypePublic { return
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() {
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryActivityPub): ctx.APIError(http.StatusForbidden, "token scope is limited to public activitypub")
if ctx.ContextUser != nil && ctx.ContextUser.IsTokenAccessAllowed() && ctx.ContextUser.Visibility != api.VisibleTypePublic { return
ctx.APIError(http.StatusForbidden, "token scope is limited to public activitypub") }
return case auth_model.AccessTokenScopeCategoryNotification:
} if !ctx.TokenCanAccessRepo(ctx.Repo.Repository) {
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryNotification): ctx.APIError(http.StatusForbidden, "token scope is limited to public notifications")
if ctx.Repo.Repository != nil && ctx.Repo.Repository.IsPrivate { return
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() {
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryPackage): ctx.APIError(http.StatusForbidden, "token scope is limited to public packages")
if ctx.Package != nil && ctx.Package.Owner.Visibility.IsPrivate() { return
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 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 // if a token is not being used, reqToken will enforce other sign in methods
func tokenRequiresScopes(requiredScopeCategories ...auth_model.AccessTokenScopeCategory) func(ctx *context.APIContext) { func tokenRequiresScopes(requiredScopeCategories ...auth_model.AccessTokenScopeCategory) func(ctx *context.APIContext) {
@ -958,6 +978,8 @@ func Routes() *web.Router {
}) })
// Notifications (requires 'notifications' scope) // 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.Group("/notifications", func() {
m.Combo(""). m.Combo("").
Get(reqToken(), notify.ListNotifications). Get(reqToken(), notify.ListNotifications).
@ -966,7 +988,7 @@ func Routes() *web.Router {
m.Combo("/threads/{id}"). m.Combo("/threads/{id}").
Get(reqToken(), notify.GetThread). Get(reqToken(), notify.GetThread).
Patch(reqToken(), notify.ReadThread) Patch(reqToken(), notify.ReadThread)
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryNotification)) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryNotification), rejectPublicOnly())
// Users (requires user scope) // Users (requires user scope)
m.Group("/users", func() { m.Group("/users", func() {
@ -1014,8 +1036,9 @@ func Routes() *web.Router {
m.Group("/settings", func() { m.Group("/settings", func() {
m.Get("", user.GetUserSettings) m.Get("", user.GetUserSettings)
m.Patch("", bind(api.UserSettingsOptions{}), user.UpdateUserSettings) m.Patch("", bind(api.UserSettingsOptions{}), user.UpdateUserSettings)
}, reqToken()) }, rejectPublicOnly())
m.Combo("/emails"). // Email addresses are always private account data.
m.Combo("/emails", rejectPublicOnly()).
Get(user.ListEmails). Get(user.ListEmails).
Post(bind(api.CreateEmailOption{}), user.AddEmail). Post(bind(api.CreateEmailOption{}), user.AddEmail).
Delete(bind(api.DeleteEmailOption{}), user.DeleteEmail) Delete(bind(api.DeleteEmailOption{}), user.DeleteEmail)
@ -1047,7 +1070,7 @@ func Routes() *web.Router {
m.Get("/runs", reqToken(), user.ListWorkflowRuns) m.Get("/runs", reqToken(), user.ListWorkflowRuns)
m.Get("/jobs", reqToken(), user.ListWorkflowJobs) m.Get("/jobs", reqToken(), user.ListWorkflowJobs)
}) }, rejectPublicOnly())
m.Get("/followers", user.ListMyFollowers) m.Get("/followers", user.ListMyFollowers)
m.Group("/following", func() { m.Group("/following", func() {
@ -1065,7 +1088,7 @@ func Routes() *web.Router {
Post(bind(api.CreateKeyOption{}), user.CreatePublicKey) Post(bind(api.CreateKeyOption{}), user.CreatePublicKey)
m.Combo("/{id}").Get(user.GetPublicKey). m.Combo("/{id}").Get(user.GetPublicKey).
Delete(user.DeletePublicKey) Delete(user.DeletePublicKey)
}) }, rejectPublicOnly())
// (admin:application scope) // (admin:application scope)
m.Group("/applications", func() { m.Group("/applications", func() {
@ -1076,7 +1099,7 @@ func Routes() *web.Router {
Delete(user.DeleteOauth2Application). Delete(user.DeleteOauth2Application).
Patch(bind(api.CreateOAuth2ApplicationOptions{}), user.UpdateOauth2Application). Patch(bind(api.CreateOAuth2ApplicationOptions{}), user.UpdateOauth2Application).
Get(user.GetOauth2Application) Get(user.GetOauth2Application)
}) }, rejectPublicOnly())
// (admin:gpg_key scope) // (admin:gpg_key scope)
m.Group("/gpg_keys", func() { m.Group("/gpg_keys", func() {
@ -1084,13 +1107,13 @@ func Routes() *web.Router {
Post(bind(api.CreateGPGKeyOption{}), user.CreateGPGKey) Post(bind(api.CreateGPGKeyOption{}), user.CreateGPGKey)
m.Combo("/{id}").Get(user.GetGPGKey). m.Combo("/{id}").Get(user.GetGPGKey).
Delete(user.DeleteGPGKey) Delete(user.DeleteGPGKey)
}) }, rejectPublicOnly())
m.Get("/gpg_key_token", user.GetVerificationToken) m.Get("/gpg_key_token", rejectPublicOnly(), user.GetVerificationToken)
m.Post("/gpg_key_verify", bind(api.VerifyGPGKeyOption{}), user.VerifyUserGPGKey) m.Post("/gpg_key_verify", rejectPublicOnly(), bind(api.VerifyGPGKeyOption{}), user.VerifyUserGPGKey)
// (repo scope) // (repo scope)
m.Combo("/repos", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)).Get(user.ListMyRepos). 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) // (repo scope)
m.Group("/starred", func() { m.Group("/starred", func() {
@ -1101,22 +1124,22 @@ func Routes() *web.Router {
m.Delete("", user.Unstar) m.Delete("", user.Unstar)
}, repoAssignment(), checkTokenPublicOnly()) }, repoAssignment(), checkTokenPublicOnly())
}, reqStarsEnabled(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)) }, reqStarsEnabled(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))
m.Get("/times", repo.ListMyTrackedTimes) m.Get("/times", rejectPublicOnly(), repo.ListMyTrackedTimes)
m.Get("/stopwatches", repo.GetStopwatches) m.Get("/stopwatches", rejectPublicOnly(), repo.GetStopwatches)
m.Get("/subscriptions", user.GetMyWatchedRepos) m.Get("/subscriptions", user.GetMyWatchedRepos)
m.Get("/teams", org.ListUserTeams) m.Get("/teams", rejectPublicOnly(), org.ListUserTeams)
m.Group("/hooks", func() { m.Group("/hooks", func() {
m.Combo("").Get(user.ListHooks). m.Combo("").Get(user.ListHooks).
Post(bind(api.CreateHookOption{}), user.CreateHook) Post(bind(api.CreateHookOption{}), user.CreateHook)
m.Combo("/{id}").Get(user.GetHook). m.Combo("/{id}").Get(user.GetHook).
Patch(bind(api.EditHookOption{}), user.EditHook). Patch(bind(api.EditHookOption{}), user.EditHook).
Delete(user.DeleteHook) Delete(user.DeleteHook)
}, reqWebhooksEnabled()) }, reqWebhooksEnabled(), rejectPublicOnly())
m.Group("/avatar", func() { m.Group("/avatar", func() {
m.Post("", bind(api.UpdateUserAvatarOption{}), user.UpdateAvatar) m.Post("", bind(api.UpdateUserAvatarOption{}), user.UpdateAvatar)
m.Delete("", user.DeleteAvatar) m.Delete("", user.DeleteAvatar)
}) }, rejectPublicOnly())
m.Group("/blocks", func() { m.Group("/blocks", func() {
m.Get("", user.ListBlocks) m.Get("", user.ListBlocks)
@ -1125,8 +1148,8 @@ func Routes() *web.Router {
m.Put("", user.BlockUser) m.Put("", user.BlockUser)
m.Delete("", user.UnblockUser) m.Delete("", user.UnblockUser)
}, context.UserAssignmentAPI(), checkTokenPublicOnly()) }, context.UserAssignmentAPI(), checkTokenPublicOnly())
}) }, rejectPublicOnly())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken(), contextAuthenticatedUser(), checkTokenPublicOnly())
// Repositories (requires repo scope, org scope) // Repositories (requires repo scope, org scope)
m.Post("/org/{org}/repos", m.Post("/org/{org}/repos",
@ -1597,7 +1620,7 @@ func Routes() *web.Router {
}, reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryPackage), context.UserAssignmentAPI(), context.PackageAssignmentAPI(), reqPackageAccess(perm.AccessModeRead), checkTokenPublicOnly()) }, reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryPackage), context.UserAssignmentAPI(), context.PackageAssignmentAPI(), reqPackageAccess(perm.AccessModeRead), checkTokenPublicOnly())
// Organizations // 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.Group("/users/{username}/orgs", func() {
m.Get("", reqToken(), org.ListUserOrgs) m.Get("", reqToken(), org.ListUserOrgs)
m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions) m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions)

View File

@ -33,6 +33,7 @@ func listUserOrgs(ctx *context.APIContext, u *user_model.User) {
UserID: u.ID, UserID: u.ID,
IncludeVisibility: organization.DoerViewOtherVisibility(ctx.Doer, u), IncludeVisibility: organization.DoerViewOtherVisibility(ctx.Doer, u),
} }
opts.ApplyPublicOnly(ctx.PublicOnly)
orgs, maxResults, err := db.FindAndCount[organization.Organization](ctx, opts) orgs, maxResults, err := db.FindAndCount[organization.Organization](ctx, opts)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
@ -192,7 +193,7 @@ func GetAll(ctx *context.APIContext) {
// "$ref": "#/responses/OrganizationList" // "$ref": "#/responses/OrganizationList"
vMode := []api.VisibleType{api.VisibleTypePublic} vMode := []api.VisibleType{api.VisibleTypePublic}
if ctx.IsSigned && !ctx.PublicOnly { if ctx.IsSigned {
vMode = append(vMode, api.VisibleTypeLimited) vMode = append(vMode, api.VisibleTypeLimited)
if ctx.Doer.IsAdmin { if ctx.Doer.IsAdmin {
vMode = append(vMode, api.VisibleTypePrivate) vMode = append(vMode, api.VisibleTypePrivate)
@ -201,13 +202,16 @@ func GetAll(ctx *context.APIContext) {
listOptions := utils.GetListOptions(ctx) listOptions := utils.GetListOptions(ctx)
publicOrgs, maxResults, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{ searchOpts := user_model.SearchUserOptions{
Actor: ctx.Doer, Actor: ctx.Doer,
ListOptions: listOptions, ListOptions: listOptions,
Types: []user_model.UserType{user_model.UserTypeOrganization}, Types: []user_model.UserType{user_model.UserTypeOrganization},
OrderBy: db.SearchOrderByAlphabetically, OrderBy: db.SearchOrderByAlphabetically,
Visible: vMode, Visible: vMode,
}) }
searchOpts.ApplyPublicOnly(ctx.PublicOnly)
publicOrgs, maxResults, err := user_model.SearchUsers(ctx, searchOpts)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
@ -487,6 +491,7 @@ func ListOrgActivityFeeds(ctx *context.APIContext) {
Date: ctx.FormString("date"), Date: ctx.FormString("date"),
ListOptions: listOptions, ListOptions: listOptions,
} }
opts.ApplyPublicOnly(ctx.PublicOnly)
feeds, count, err := feed_service.GetFeeds(ctx, opts) feeds, count, err := feed_service.GetFeeds(ctx, opts)
if err != nil { if err != nil {

View File

@ -47,9 +47,10 @@ func buildSearchIssuesRepoIDs(ctx *context.APIContext) (repoIDs []int64, allPubl
Actor: ctx.Doer, Actor: ctx.Doer,
} }
if ctx.IsSigned { if ctx.IsSigned {
opts.Private = !ctx.PublicOnly opts.Private = true
opts.AllLimited = true opts.AllLimited = true
} }
opts.ApplyPublicOnly(ctx.PublicOnly)
if ctx.FormString("owner") != "" { if ctx.FormString("owner") != "" {
owner, err := user_model.GetUserByName(ctx, ctx.FormString("owner")) owner, err := user_model.GetUserByName(ctx, ctx.FormString("owner"))
if err != nil { if err != nil {

View File

@ -131,9 +131,6 @@ func Search(ctx *context.APIContext) {
// "$ref": "#/responses/validationError" // "$ref": "#/responses/validationError"
private := ctx.IsSigned && (ctx.FormString("private") == "" || ctx.FormBool("private")) private := ctx.IsSigned && (ctx.FormString("private") == "" || ctx.FormBool("private"))
if ctx.PublicOnly {
private = false
}
opts := repo_model.SearchRepoOptions{ opts := repo_model.SearchRepoOptions{
ListOptions: utils.GetListOptions(ctx), ListOptions: utils.GetListOptions(ctx),
@ -149,6 +146,7 @@ func Search(ctx *context.APIContext) {
StarredByID: ctx.FormInt64("starredBy"), StarredByID: ctx.FormInt64("starredBy"),
IncludeDescription: ctx.FormBool("includeDesc"), IncludeDescription: ctx.FormBool("includeDesc"),
} }
opts.ApplyPublicOnly(ctx.PublicOnly)
if ctx.FormString("template") != "" { if ctx.FormString("template") != "" {
opts.Template = optional.Some(ctx.FormBool("template")) opts.Template = optional.Some(ctx.FormBool("template"))
@ -567,6 +565,10 @@ func GetByID(ctx *context.APIContext) {
} }
return return
} }
if !ctx.TokenCanAccessRepo(repo) {
ctx.APIErrorNotFound()
return
}
permission, err := access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer) permission, err := access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer)
if err != nil { if err != nil {
@ -1254,6 +1256,7 @@ func ListRepoActivityFeeds(ctx *context.APIContext) {
Date: ctx.FormString("date"), Date: ctx.FormString("date"),
ListOptions: listOptions, ListOptions: listOptions,
} }
opts.ApplyPublicOnly(ctx.PublicOnly)
feeds, count, err := feed_service.GetFeeds(ctx, opts) feeds, count, err := feed_service.GetFeeds(ctx, opts)
if err != nil { if err != nil {

View File

@ -19,12 +19,15 @@ import (
func listUserRepos(ctx *context.APIContext, u *user_model.User, private bool) { func listUserRepos(ctx *context.APIContext, u *user_model.User, private bool) {
opts := utils.GetListOptions(ctx) opts := utils.GetListOptions(ctx)
repos, count, err := repo_model.GetUserRepositories(ctx, repo_model.SearchRepoOptions{ searchOpts := repo_model.SearchRepoOptions{
Actor: u, Actor: u,
Private: private, Private: private,
ListOptions: opts, ListOptions: opts,
OrderBy: "id ASC", OrderBy: "id ASC",
}) }
searchOpts.ApplyPublicOnly(ctx.PublicOnly)
repos, count, err := repo_model.GetUserRepositories(ctx, searchOpts)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
@ -79,8 +82,7 @@ func ListUserRepos(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
private := ctx.IsSigned listUserRepos(ctx, ctx.ContextUser, ctx.IsSigned)
listUserRepos(ctx, ctx.ContextUser, private)
} }
// ListMyRepos - list the repositories you own or have access to. // ListMyRepos - list the repositories you own or have access to.
@ -110,6 +112,7 @@ func ListMyRepos(ctx *context.APIContext) {
Private: ctx.IsSigned, Private: ctx.IsSigned,
IncludeDescription: true, IncludeDescription: true,
} }
opts.ApplyPublicOnly(ctx.PublicOnly)
repos, count, err := repo_model.SearchRepository(ctx, opts) repos, count, err := repo_model.SearchRepository(ctx, opts)
if err != nil { if err != nil {

View File

@ -20,11 +20,14 @@ import (
// getStarredRepos returns the repos that the user with the specified userID has // getStarredRepos returns the repos that the user with the specified userID has
// starred // starred
func getStarredRepos(ctx *context.APIContext, user *user_model.User, private bool) ([]*api.Repository, error) { 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), ListOptions: utils.GetListOptions(ctx),
StarrerID: user.ID, StarrerID: user.ID,
IncludePrivate: private, IncludePrivate: private,
}) }
opts.ApplyPublicOnly(ctx.PublicOnly)
starredRepos, err := repo_model.GetStarredRepos(ctx, opts)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -9,7 +9,6 @@ import (
activities_model "code.gitea.io/gitea/models/activities" activities_model "code.gitea.io/gitea/models/activities"
user_model "code.gitea.io/gitea/models/user" 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/routers/api/v1/utils"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert" "code.gitea.io/gitea/services/convert"
@ -69,19 +68,16 @@ func Search(ctx *context.APIContext) {
maxResults = 1 maxResults = 1
users = []*user_model.User{user_model.NewActionsUser()} users = []*user_model.User{user_model.NewActionsUser()}
default: default:
var visible []structs.VisibleType opts := user_model.SearchUserOptions{
if ctx.PublicOnly {
visible = []structs.VisibleType{structs.VisibleTypePublic}
}
users, maxResults, err = user_model.SearchUsers(ctx, user_model.SearchUserOptions{
Actor: ctx.Doer, Actor: ctx.Doer,
Keyword: ctx.FormTrim("q"), Keyword: ctx.FormTrim("q"),
UID: uid, UID: uid,
Types: []user_model.UserType{user_model.UserTypeIndividual}, Types: []user_model.UserType{user_model.UserTypeIndividual},
SearchByEmail: true, SearchByEmail: true,
Visible: visible,
ListOptions: listOptions, ListOptions: listOptions,
}) }
opts.ApplyPublicOnly(ctx.PublicOnly)
users, maxResults, err = user_model.SearchUsers(ctx, opts)
if err != nil { if err != nil {
ctx.JSON(http.StatusInternalServerError, map[string]any{ ctx.JSON(http.StatusInternalServerError, map[string]any{
"ok": false, "ok": false,
@ -214,6 +210,7 @@ func ListUserActivityFeeds(ctx *context.APIContext) {
Date: ctx.FormString("date"), Date: ctx.FormString("date"),
ListOptions: listOptions, ListOptions: listOptions,
} }
opts.ApplyPublicOnly(ctx.PublicOnly)
feeds, count, err := feed_service.GetFeeds(ctx, opts) feeds, count, err := feed_service.GetFeeds(ctx, opts)
if err != nil { if err != nil {

View File

@ -18,11 +18,14 @@ import (
// getWatchedRepos returns the repos that the user with the specified userID is watching // 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) { 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), ListOptions: utils.GetListOptions(ctx),
WatcherID: user.ID, WatcherID: user.ID,
IncludePrivate: private, IncludePrivate: private,
}) }
opts.ApplyPublicOnly(ctx.PublicOnly)
watchedRepos, total, err := repo_model.GetWatchedRepos(ctx, opts)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }

View File

@ -13,6 +13,7 @@ import (
"strconv" "strconv"
"strings" "strings"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/cache" "code.gitea.io/gitea/modules/cache"
@ -47,6 +48,12 @@ type APIContext struct {
PublicOnly bool // Whether the request is for a public endpoint 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() { func init() {
web.RegisterResponseStatusProvider[*APIContext](func(req *http.Request) web_types.ResponseStatusProvider { web.RegisterResponseStatusProvider[*APIContext](func(req *http.Request) web_types.ResponseStatusProvider {
return req.Context().Value(apiContextKey).(*APIContext) return req.Context().Value(apiContextKey).(*APIContext)

View File

@ -108,7 +108,7 @@ func testAPIListIssuesPublicOnly(t *testing.T) {
publicOnlyToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue, auth_model.AccessTokenScopePublicOnly) publicOnlyToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue, auth_model.AccessTokenScopePublicOnly)
req = NewRequest(t, "GET", link.String()).AddTokenAuth(publicOnlyToken) req = NewRequest(t, "GET", link.String()).AddTokenAuth(publicOnlyToken)
MakeRequest(t, req, http.StatusForbidden) MakeRequest(t, req, http.StatusNotFound)
} }
func testAPICreateIssue(t *testing.T) { func testAPICreateIssue(t *testing.T) {

View File

@ -212,3 +212,23 @@ func TestAPINotificationPUT(t *testing.T) {
assert.True(t, apiNL[0].Unread) assert.True(t, apiNL[0].Unread)
assert.False(t, apiNL[0].Pinned) 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)
}

View File

@ -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)
}
}
}

View File

@ -29,10 +29,10 @@ func TestAPIRepoBranchesPlain(t *testing.T) {
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
session := loginUser(t, user1.LowerName) 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) 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 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) token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
resp := MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK) 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) assert.Equal(t, "master", branches[1].Name)
link2, _ := url.Parse(fmt.Sprintf("/api/v1/repos/org3/%s/branches/test_branch", repo3.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) resp = MakeRequest(t, NewRequest(t, "GET", link2.String()).AddTokenAuth(token), http.StatusOK)
bs, err = io.ReadAll(resp.Body) bs, err = io.ReadAll(resp.Body)
@ -55,7 +55,7 @@ func TestAPIRepoBranchesPlain(t *testing.T) {
assert.NoError(t, json.Unmarshal(bs, &branch)) assert.NoError(t, json.Unmarshal(bs, &branch))
assert.Equal(t, "test_branch", branch.Name) 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 := NewRequest(t, "POST", link.String()).AddTokenAuth(token)
req.Header.Add("Content-Type", "application/json") 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)) 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()), 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) MakeRequest(t, NewRequest(t, "DELETE", link3.String()).AddTokenAuth(token), http.StatusNoContent)
assert.NoError(t, err) assert.NoError(t, err)

View File

@ -155,3 +155,44 @@ func TestMyOrgs(t *testing.T) {
}, },
}, orgs) }, 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)
}

View File

@ -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)
})
}

View File

@ -17,6 +17,7 @@ import (
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestAPIStar(t *testing.T) { func TestAPIStar(t *testing.T) {
@ -155,3 +156,24 @@ func TestAPIStarDisabled(t *testing.T) {
MakeRequest(t, req, http.StatusForbidden) 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)
}

View File

@ -94,3 +94,28 @@ func TestAPIWatch(t *testing.T) {
MakeRequest(t, req, http.StatusNoContent) 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")
}