Skip to content

Commit f2a1271

Browse files
lunnysilverwindclaude
authored
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 <me@silverwind.io> Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
1 parent 81b544c commit f2a1271

22 files changed

Lines changed: 561 additions & 87 deletions

models/activities/action.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,12 @@ type GetFeedsOptions struct {
436436
DontCount bool // do counting in GetFeeds
437437
}
438438

439+
func (opts *GetFeedsOptions) ApplyPublicOnly(publicOnly bool) {
440+
if publicOnly {
441+
opts.IncludePrivate = false
442+
}
443+
}
444+
439445
// ActivityReadable return whether doer can read activities of user
440446
func ActivityReadable(user, doer *user_model.User) bool {
441447
return !user.KeepActivityPrivate ||

models/organization/org_list.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ type FindOrgOptions struct {
5454
IncludeVisibility structs.VisibleType
5555
}
5656

57+
func (opts *FindOrgOptions) ApplyPublicOnly(publicOnly bool) {
58+
if publicOnly {
59+
opts.IncludeVisibility = structs.VisibleTypePublic
60+
}
61+
}
62+
5763
func queryUserOrgIDs(userID int64, includePrivate bool) *builder.Builder {
5864
cond := builder.Eq{"uid": userID}
5965
if !includePrivate {

models/repo/repo_list.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,13 @@ type SearchRepoOptions struct {
212212
OnlyShowRelevant bool
213213
}
214214

215+
func (opts *SearchRepoOptions) ApplyPublicOnly(publicOnly bool) {
216+
if publicOnly {
217+
opts.Private = false
218+
opts.AllLimited = false
219+
}
220+
}
221+
215222
// UserOwnedRepoCond returns user ownered repositories
216223
func UserOwnedRepoCond(userID int64) builder.Cond {
217224
return builder.Eq{

models/repo/user_repo.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ type StarredReposOptions struct {
2424
IncludePrivate bool
2525
}
2626

27+
func (opts *StarredReposOptions) ApplyPublicOnly(publicOnly bool) {
28+
if publicOnly {
29+
opts.IncludePrivate = false
30+
}
31+
}
32+
2733
func (opts *StarredReposOptions) ToConds() builder.Cond {
2834
var cond builder.Cond = builder.Eq{
2935
"star.uid": opts.StarrerID,
@@ -62,6 +68,12 @@ type WatchedReposOptions struct {
6268
IncludePrivate bool
6369
}
6470

71+
func (opts *WatchedReposOptions) ApplyPublicOnly(publicOnly bool) {
72+
if publicOnly {
73+
opts.IncludePrivate = false
74+
}
75+
}
76+
6577
func (opts *WatchedReposOptions) ToConds() builder.Cond {
6678
var cond builder.Cond = builder.Eq{
6779
"watch.user_id": opts.WatcherID,

models/user/search.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ type SearchUserOptions struct {
5858
IncludeReserved bool
5959
}
6060

61+
func (opts *SearchUserOptions) ApplyPublicOnly(publicOnly bool) {
62+
if publicOnly {
63+
opts.Visible = []structs.VisibleType{structs.VisibleTypePublic}
64+
}
65+
}
66+
6167
func (opts *SearchUserOptions) toSearchQueryBase(ctx context.Context) db.Session {
6268
var cond builder.Cond
6369
cond = builder.In("type", opts.Types)

routers/api/v1/api.go

Lines changed: 81 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,11 @@ func repoAssignment() func(ctx *context.APIContext) {
212212
ctx.APIErrorNotFound()
213213
return
214214
}
215+
216+
if !ctx.TokenCanAccessRepo(repo) {
217+
ctx.APIErrorNotFound()
218+
return
219+
}
215220
}
216221
}
217222

@@ -249,51 +254,66 @@ func checkTokenPublicOnly() func(ctx *context.APIContext) {
249254
return
250255
}
251256

252-
// public Only permission check
253-
switch {
254-
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryRepository):
255-
if ctx.Repo.Repository != nil && ctx.Repo.Repository.IsPrivate {
256-
ctx.APIError(http.StatusForbidden, "token scope is limited to public repos")
257-
return
258-
}
259-
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryIssue):
260-
if ctx.Repo.Repository != nil && ctx.Repo.Repository.IsPrivate {
261-
ctx.APIError(http.StatusForbidden, "token scope is limited to public issues")
262-
return
263-
}
264-
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryOrganization):
265-
if ctx.Org.Organization != nil && ctx.Org.Organization.Visibility != api.VisibleTypePublic {
266-
ctx.APIError(http.StatusForbidden, "token scope is limited to public orgs")
267-
return
268-
}
269-
if ctx.ContextUser != nil && ctx.ContextUser.IsOrganization() && ctx.ContextUser.Visibility != api.VisibleTypePublic {
270-
ctx.APIError(http.StatusForbidden, "token scope is limited to public orgs")
271-
return
272-
}
273-
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryUser):
274-
if ctx.ContextUser != nil && ctx.ContextUser.IsTokenAccessAllowed() && ctx.ContextUser.Visibility != api.VisibleTypePublic {
275-
ctx.APIError(http.StatusForbidden, "token scope is limited to public users")
276-
return
277-
}
278-
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryActivityPub):
279-
if ctx.ContextUser != nil && ctx.ContextUser.IsTokenAccessAllowed() && ctx.ContextUser.Visibility != api.VisibleTypePublic {
280-
ctx.APIError(http.StatusForbidden, "token scope is limited to public activitypub")
281-
return
282-
}
283-
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryNotification):
284-
if ctx.Repo.Repository != nil && ctx.Repo.Repository.IsPrivate {
285-
ctx.APIError(http.StatusForbidden, "token scope is limited to public notifications")
286-
return
287-
}
288-
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryPackage):
289-
if ctx.Package != nil && ctx.Package.Owner.Visibility.IsPrivate() {
290-
ctx.APIError(http.StatusForbidden, "token scope is limited to public packages")
291-
return
257+
for _, category := range requiredScopeCategories {
258+
switch category {
259+
case auth_model.AccessTokenScopeCategoryRepository:
260+
if !ctx.TokenCanAccessRepo(ctx.Repo.Repository) {
261+
ctx.APIError(http.StatusForbidden, "token scope is limited to public repos")
262+
return
263+
}
264+
case auth_model.AccessTokenScopeCategoryIssue:
265+
if !ctx.TokenCanAccessRepo(ctx.Repo.Repository) {
266+
ctx.APIError(http.StatusForbidden, "token scope is limited to public issues")
267+
return
268+
}
269+
case auth_model.AccessTokenScopeCategoryOrganization:
270+
orgPrivate := ctx.Org.Organization != nil && !ctx.Org.Organization.Visibility.IsPublic()
271+
userOrgPrivate := ctx.ContextUser != nil && ctx.ContextUser.IsOrganization() && !ctx.ContextUser.Visibility.IsPublic()
272+
if orgPrivate || userOrgPrivate {
273+
ctx.APIError(http.StatusForbidden, "token scope is limited to public orgs")
274+
return
275+
}
276+
case auth_model.AccessTokenScopeCategoryUser:
277+
if ctx.ContextUser != nil && ctx.ContextUser.IsTokenAccessAllowed() && !ctx.ContextUser.Visibility.IsPublic() {
278+
ctx.APIError(http.StatusForbidden, "token scope is limited to public users")
279+
return
280+
}
281+
case auth_model.AccessTokenScopeCategoryActivityPub:
282+
if ctx.ContextUser != nil && ctx.ContextUser.IsTokenAccessAllowed() && !ctx.ContextUser.Visibility.IsPublic() {
283+
ctx.APIError(http.StatusForbidden, "token scope is limited to public activitypub")
284+
return
285+
}
286+
case auth_model.AccessTokenScopeCategoryNotification:
287+
if !ctx.TokenCanAccessRepo(ctx.Repo.Repository) {
288+
ctx.APIError(http.StatusForbidden, "token scope is limited to public notifications")
289+
return
290+
}
291+
case auth_model.AccessTokenScopeCategoryPackage:
292+
if ctx.Package != nil && ctx.Package.Owner.Visibility.IsPrivate() {
293+
ctx.APIError(http.StatusForbidden, "token scope is limited to public packages")
294+
return
295+
}
292296
}
293297
}
294298
}
295299
}
296300

301+
func rejectPublicOnly() func(ctx *context.APIContext) {
302+
return func(ctx *context.APIContext) {
303+
if !ctx.PublicOnly {
304+
return
305+
}
306+
307+
ctx.APIError(http.StatusForbidden, "this endpoint is not available for public-only tokens")
308+
}
309+
}
310+
311+
func contextAuthenticatedUser() func(ctx *context.APIContext) {
312+
return func(ctx *context.APIContext) {
313+
ctx.ContextUser = ctx.Doer
314+
}
315+
}
316+
297317
// if a token is being used for auth, we check that it contains the required scope
298318
// if a token is not being used, reqToken will enforce other sign in methods
299319
func tokenRequiresScopes(requiredScopeCategories ...auth_model.AccessTokenScopeCategory) func(ctx *context.APIContext) {
@@ -957,6 +977,8 @@ func Routes() *web.Router {
957977
})
958978

959979
// Notifications (requires 'notifications' scope)
980+
// The notifications API is not available for public-only tokens because a user's notifications mix
981+
// public and private repository events in the same mailbox.
960982
m.Group("/notifications", func() {
961983
m.Combo("").
962984
Get(reqToken(), notify.ListNotifications).
@@ -965,7 +987,7 @@ func Routes() *web.Router {
965987
m.Combo("/threads/{id}").
966988
Get(reqToken(), notify.GetThread).
967989
Patch(reqToken(), notify.ReadThread)
968-
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryNotification))
990+
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryNotification), rejectPublicOnly())
969991

970992
// Users (requires user scope)
971993
m.Group("/users", func() {
@@ -1013,8 +1035,9 @@ func Routes() *web.Router {
10131035
m.Group("/settings", func() {
10141036
m.Get("", user.GetUserSettings)
10151037
m.Patch("", bind(api.UserSettingsOptions{}), user.UpdateUserSettings)
1016-
}, reqToken())
1017-
m.Combo("/emails").
1038+
}, rejectPublicOnly())
1039+
// Email addresses are always private account data.
1040+
m.Combo("/emails", rejectPublicOnly()).
10181041
Get(user.ListEmails).
10191042
Post(bind(api.CreateEmailOption{}), user.AddEmail).
10201043
Delete(bind(api.DeleteEmailOption{}), user.DeleteEmail)
@@ -1046,7 +1069,7 @@ func Routes() *web.Router {
10461069

10471070
m.Get("/runs", reqToken(), user.ListWorkflowRuns)
10481071
m.Get("/jobs", reqToken(), user.ListWorkflowJobs)
1049-
})
1072+
}, rejectPublicOnly())
10501073

10511074
m.Get("/followers", user.ListMyFollowers)
10521075
m.Group("/following", func() {
@@ -1064,7 +1087,7 @@ func Routes() *web.Router {
10641087
Post(bind(api.CreateKeyOption{}), user.CreatePublicKey)
10651088
m.Combo("/{id}").Get(user.GetPublicKey).
10661089
Delete(user.DeletePublicKey)
1067-
})
1090+
}, rejectPublicOnly())
10681091

10691092
// (admin:application scope)
10701093
m.Group("/applications", func() {
@@ -1075,21 +1098,21 @@ func Routes() *web.Router {
10751098
Delete(user.DeleteOauth2Application).
10761099
Patch(bind(api.CreateOAuth2ApplicationOptions{}), user.UpdateOauth2Application).
10771100
Get(user.GetOauth2Application)
1078-
})
1101+
}, rejectPublicOnly())
10791102

10801103
// (admin:gpg_key scope)
10811104
m.Group("/gpg_keys", func() {
10821105
m.Combo("").Get(user.ListMyGPGKeys).
10831106
Post(bind(api.CreateGPGKeyOption{}), user.CreateGPGKey)
10841107
m.Combo("/{id}").Get(user.GetGPGKey).
10851108
Delete(user.DeleteGPGKey)
1086-
})
1087-
m.Get("/gpg_key_token", user.GetVerificationToken)
1088-
m.Post("/gpg_key_verify", bind(api.VerifyGPGKeyOption{}), user.VerifyUserGPGKey)
1109+
}, rejectPublicOnly())
1110+
m.Get("/gpg_key_token", rejectPublicOnly(), user.GetVerificationToken)
1111+
m.Post("/gpg_key_verify", rejectPublicOnly(), bind(api.VerifyGPGKeyOption{}), user.VerifyUserGPGKey)
10891112

10901113
// (repo scope)
10911114
m.Combo("/repos", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)).Get(user.ListMyRepos).
1092-
Post(bind(api.CreateRepoOption{}), repo.Create)
1115+
Post(rejectPublicOnly(), bind(api.CreateRepoOption{}), repo.Create)
10931116

10941117
// (repo scope)
10951118
m.Group("/starred", func() {
@@ -1100,22 +1123,22 @@ func Routes() *web.Router {
11001123
m.Delete("", user.Unstar)
11011124
}, repoAssignment(), checkTokenPublicOnly())
11021125
}, reqStarsEnabled(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))
1103-
m.Get("/times", repo.ListMyTrackedTimes)
1104-
m.Get("/stopwatches", repo.GetStopwatches)
1126+
m.Get("/times", rejectPublicOnly(), repo.ListMyTrackedTimes)
1127+
m.Get("/stopwatches", rejectPublicOnly(), repo.GetStopwatches)
11051128
m.Get("/subscriptions", user.GetMyWatchedRepos)
1106-
m.Get("/teams", org.ListUserTeams)
1129+
m.Get("/teams", rejectPublicOnly(), org.ListUserTeams)
11071130
m.Group("/hooks", func() {
11081131
m.Combo("").Get(user.ListHooks).
11091132
Post(bind(api.CreateHookOption{}), user.CreateHook)
11101133
m.Combo("/{id}").Get(user.GetHook).
11111134
Patch(bind(api.EditHookOption{}), user.EditHook).
11121135
Delete(user.DeleteHook)
1113-
}, reqWebhooksEnabled())
1136+
}, reqWebhooksEnabled(), rejectPublicOnly())
11141137

11151138
m.Group("/avatar", func() {
11161139
m.Post("", bind(api.UpdateUserAvatarOption{}), user.UpdateAvatar)
11171140
m.Delete("", user.DeleteAvatar)
1118-
})
1141+
}, rejectPublicOnly())
11191142

11201143
m.Group("/blocks", func() {
11211144
m.Get("", user.ListBlocks)
@@ -1124,8 +1147,8 @@ func Routes() *web.Router {
11241147
m.Put("", user.BlockUser)
11251148
m.Delete("", user.UnblockUser)
11261149
}, context.UserAssignmentAPI(), checkTokenPublicOnly())
1127-
})
1128-
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken())
1150+
}, rejectPublicOnly())
1151+
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken(), contextAuthenticatedUser(), checkTokenPublicOnly())
11291152

11301153
// Repositories (requires repo scope, org scope)
11311154
m.Post("/org/{org}/repos",
@@ -1601,7 +1624,7 @@ func Routes() *web.Router {
16011624
}, reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryPackage), context.UserAssignmentAPI(), context.PackageAssignmentAPI(), reqPackageAccess(perm.AccessModeRead), checkTokenPublicOnly())
16021625

16031626
// Organizations
1604-
m.Get("/user/orgs", reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), org.ListMyOrgs)
1627+
m.Get("/user/orgs", reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), checkTokenPublicOnly(), org.ListMyOrgs)
16051628
m.Group("/users/{username}/orgs", func() {
16061629
m.Get("", reqToken(), org.ListUserOrgs)
16071630
m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions)

routers/api/v1/org/org.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ func listUserOrgs(ctx *context.APIContext, u *user_model.User) {
4040
UserID: u.ID,
4141
IncludeVisibility: organization.DoerViewOtherVisibility(ctx.Doer, u),
4242
}
43+
opts.ApplyPublicOnly(ctx.PublicOnly)
4344
orgs, maxResults, err := db.FindAndCount[organization.Organization](ctx, opts)
4445
if err != nil {
4546
ctx.APIErrorInternal(err)
@@ -199,7 +200,7 @@ func GetAll(ctx *context.APIContext) {
199200
// "$ref": "#/responses/OrganizationList"
200201

201202
vMode := []api.VisibleType{api.VisibleTypePublic}
202-
if ctx.IsSigned && !ctx.PublicOnly {
203+
if ctx.IsSigned {
203204
vMode = append(vMode, api.VisibleTypeLimited)
204205
if ctx.Doer.IsAdmin {
205206
vMode = append(vMode, api.VisibleTypePrivate)
@@ -208,13 +209,16 @@ func GetAll(ctx *context.APIContext) {
208209

209210
listOptions := utils.GetListOptions(ctx)
210211

211-
publicOrgs, maxResults, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{
212+
searchOpts := user_model.SearchUserOptions{
212213
Actor: ctx.Doer,
213214
ListOptions: listOptions,
214215
Types: []user_model.UserType{user_model.UserTypeOrganization},
215216
OrderBy: db.SearchOrderByAlphabetically,
216217
Visible: vMode,
217-
})
218+
}
219+
searchOpts.ApplyPublicOnly(ctx.PublicOnly)
220+
221+
publicOrgs, maxResults, err := user_model.SearchUsers(ctx, searchOpts)
218222
if err != nil {
219223
ctx.APIErrorInternal(err)
220224
return
@@ -494,6 +498,7 @@ func ListOrgActivityFeeds(ctx *context.APIContext) {
494498
Date: ctx.FormString("date"),
495499
ListOptions: listOptions,
496500
}
501+
opts.ApplyPublicOnly(ctx.PublicOnly)
497502

498503
feeds, count, err := feed_service.GetFeeds(ctx, opts)
499504
if err != nil {

routers/api/v1/repo/issue.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,10 @@ func buildSearchIssuesRepoIDs(ctx *context.APIContext) (repoIDs []int64, allPubl
4747
Actor: ctx.Doer,
4848
}
4949
if ctx.IsSigned {
50-
opts.Private = !ctx.PublicOnly
50+
opts.Private = true
5151
opts.AllLimited = true
5252
}
53+
opts.ApplyPublicOnly(ctx.PublicOnly)
5354
if ctx.FormString("owner") != "" {
5455
owner, err := user_model.GetUserByName(ctx, ctx.FormString("owner"))
5556
if err != nil {

0 commit comments

Comments
 (0)