fix: harden context user access scope (#39945)

API tokens and Actions task tokens could see data beyond their scope
through user-scoped API routes and web profile pages.

- Actions task tokens authenticate as a shared system user with a
negative ID, which org, project and SSH/GPG key filters treated as "no
owner filter". These filters now check `!= 0`, so system users own
nothing. The `/api/v1/user` routes except `GET /api/v1/user` only accept
individual and bot accounts, and repo search no longer skips access
checks for the owner.
- `/{username}` only accepts tokens for RSS/Atom feeds. HTML profile and
org pages render as anonymous for token requests.
- User and org feeds require `read:user` or `read:organization` from
tokens, the same way repo feeds require `read:repository`.

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-31 13:03:59 +01:00
1 parent 365cb2bd5e
commit 6782d0a37d
11 files changed
+71 -39

No files matched your search

+1 -1
View File
@@ -85,7 +85,7 @@ func (opts FindGPGKeyOptions) ToConds() builder.Cond {
cond = cond.And(builder.Eq{"primary_key_id": ""})
}
if opts.OwnerID > 0 {
if opts.OwnerID != 0 {
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
}
if opts.KeyID != "" {
+1 -1
View File
@@ -224,7 +224,7 @@ func (opts FindPublicKeyOptions) ToOrders() string {
func (opts FindPublicKeyOptions) ToConds() builder.Cond {
cond := builder.NewCond()
if opts.OwnerID > 0 {
if opts.OwnerID != 0 {
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
}
if opts.Fingerprint != "" {
+1 -1
View File
@@ -64,7 +64,7 @@ func queryUserOrgIDs(userID int64, includePrivate bool) *builder.Builder {
func (opts FindOrgOptions) ToConds() builder.Cond {
var cond builder.Cond = builder.Eq{"`user`.`type`": user_model.UserTypeOrganization}
if opts.UserID > 0 {
if opts.UserID != 0 {
cond = cond.And(builder.In("`user`.`id`", queryUserOrgIDs(opts.UserID, opts.IncludeVisibility == structs.VisibleTypePrivate)))
}
// public=0, limited=1, private=2
+1 -1
View File
@@ -225,7 +225,7 @@ func (opts SearchOptions) ToConds() builder.Cond {
if opts.Type > 0 {
cond = cond.And(builder.Eq{"type": opts.Type})
}
if opts.OwnerID > 0 {
if opts.OwnerID != 0 {
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
}
+1 -2
View File
@@ -369,8 +369,7 @@ func SearchRepositoryCondition(opts SearchRepoOptions) builder.Cond {
cond := builder.NewCond()
if opts.Private {
if opts.Actor != nil && !opts.Actor.IsAdmin && opts.Actor.ID != opts.OwnerID {
// OK we're in the context of a User
if opts.Actor != nil && !opts.Actor.IsAdmin {
cond = cond.And(AccessibleRepositoryCondition(opts.Actor, unit.TypeInvalid))
}
} else {
+12 -4
View File
@@ -256,12 +256,18 @@ func rejectPublicOnly() func(ctx *context.APIContext) {
}
}
func contextAuthenticatedUser() func(ctx *context.APIContext) {
func authenticatedUserAsContextUser(allowSystemUser bool) func(ctx *context.APIContext) {
return func(ctx *context.APIContext) {
if !user_model.DoerTokenAllowsOwner(ctx.Doer, ctx.Doer) {
ctx.APIError(http.StatusForbidden, "this endpoint is not available for public-only tokens")
return
}
isIndividualOrBot := ctx.Doer.IsIndividual() || ctx.Doer.IsTypeBot()
isSystem := ctx.Doer.ID < 0
if !isIndividualOrBot || isSystem && !allowSystemUser {
ctx.APIError(http.StatusForbidden, "this endpoint is only available for individual user or bot accounts")
return
}
ctx.ContextUser = ctx.Doer
}
}
@@ -1097,8 +1103,11 @@ func Routes() *web.Router {
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken())
// Users (requires user scope)
contextRealDoerUser := authenticatedUserAsContextUser(false)
contextRealOrSystemUser := authenticatedUserAsContextUser(true)
m.Get("/user", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken(), contextRealOrSystemUser, user.GetAuthenticatedUser)
m.Group("/user", func() {
m.Get("", user.GetAuthenticatedUser)
m.Get("/orgs", reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), org.ListMyOrgs)
m.Group("/settings", func() {
m.Get("", user.GetUserSettings)
m.Patch("", bind(api.UserSettingsOptions{}), user.UpdateUserSettings)
@@ -1218,7 +1227,7 @@ func Routes() *web.Router {
m.Delete("", user.UnblockUser)
}, context.UserAssignmentAPI())
}, rejectPublicOnly())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken(), contextAuthenticatedUser())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken(), contextRealDoerUser)
// Repositories (requires repo scope, org scope)
m.Post("/org/{org}/repos",
@@ -1719,7 +1728,6 @@ func Routes() *web.Router {
}, reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryPackage), context.UserAssignmentAPI(), context.PackageAssignmentAPI(), reqPackageAccess(perm.AccessModeRead))
// Organizations
m.Get("/user/orgs", reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), org.ListMyOrgs)
m.Group("/users/{username}/orgs", func() {
m.Get("", reqToken(), org.ListUserOrgs)
m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions)
+1
View File
@@ -126,6 +126,7 @@ func ListMyRepos(ctx *context.APIContext) {
permission, err := access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer)
if err != nil {
ctx.APIErrorInternal(err)
return
}
results[i] = convert.ToRepo(ctx, repo, permission)
}
+6
View File
@@ -7,10 +7,12 @@ import (
"time"
activities_model "gitea.dev/models/activities"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/organization"
"gitea.dev/models/renderhelper"
"gitea.dev/modules/markup/markdown"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
"gitea.dev/services/context"
feed_service "gitea.dev/services/feed"
@@ -33,6 +35,10 @@ func showUserFeed(ctx *context.Context, formatType string) {
ctx.NotFound(nil)
return
}
context.CheckTokenScopes(ctx, util.Iif(ctx.ContextUser.IsOrganization(), auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopeReadUser))
if ctx.Written() {
return
}
includePrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID)
if ctx.IsSigned && ctx.ContextUser.IsOrganization() && !includePrivate {
+4 -3
View File
@@ -917,10 +917,11 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("/-/ownername.png/{username}", user.AvatarByUsernameSize)
m.Get("/-/ownername.keys/{username}", user.ShowSSHKeys)
m.Get("/-/ownername.gpg/{username}", user.ShowGPGKeys)
m.Get("/-/ownername.rss/{username}", feed.ShowUserFeedRSS)
m.Get("/-/ownername.atom/{username}", feed.ShowUserFeedAtom)
// tokens only authenticate feeds, the other variants render data outside any token scope
m.Get("/-/ownername.rss/{username}", webAuth.AllowBasic, feed.ShowUserFeedRSS)
m.Get("/-/ownername.atom/{username}", webAuth.AllowBasic, feed.ShowUserFeedAtom)
m.Get("/{username}", user.OwnerProfile)
}, webAuth.AllowBasic, optSignIn, context.UserAssignmentWeb(), context.ReqContextUserVisibleToDoer())
}, optSignIn, context.UserAssignmentWeb(), context.ReqContextUserVisibleToDoer())
m.Post("/{username}", reqSignIn, context.UserAssignmentWeb(), context.ReqContextUserVisibleToDoer(), user.ActionUserFollow)
@@ -24,6 +24,17 @@ func testActionUserSignIn(t *testing.T) {
assert.Equal(t, "gitea-actions", u.UserName)
}
func testActionUserOwnsNothing(t *testing.T) {
const token = "8061e833a55f6fc0157c98b883e91fcfeeb1a71a"
for _, link := range []string{"/api/v1/user/repos", "/api/v1/user/orgs", "/api/v1/user/actions/runs"} {
MakeRequest(t, NewRequest(t, "GET", link).AddTokenAuth(token), http.StatusForbidden)
}
for _, link := range []string{"/api/v1/users/gitea-actions/orgs", "/api/v1/users/gitea-actions/keys"} {
assert.JSONEq(t, "[]", MakeRequest(t, NewRequest(t, "GET", link).AddTokenAuth(token), http.StatusOK).Body.String(), link)
}
assert.NotContains(t, MakeRequest(t, NewRequest(t, "GET", "/api/v1/repos/search?uid=-2").AddTokenAuth(token), http.StatusOK).Body.String(), `"private":true`)
}
func testActionUserAccessPublicRepo(t *testing.T) {
req := NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/raw/README.md").
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
@@ -48,6 +59,7 @@ func TestActionUserAccessPermission(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("ActionUserSignIn", testActionUserSignIn)
t.Run("ActionUserOwnsNothing", testActionUserOwnsNothing)
t.Run("ActionUserAccessPublicRepo", testActionUserAccessPublicRepo)
t.Run("ActionUserNoAccessOtherPrivateRepo", testActionUserNoAccessOtherPrivateRepo)
}
+31 -26
View File
@@ -6,6 +6,7 @@ package integration
import (
"encoding/xml"
"net/http"
"strings"
"testing"
auth_model "gitea.dev/models/auth"
@@ -38,6 +39,36 @@ type RSS struct {
func TestFeedUser(t *testing.T) {
defer tests.PrepareTestEnv(t)()
resp := MakeRequest(t, NewRequest(t, "GET", "/user2.atom"), http.StatusOK)
assert.Contains(t, resp.Body.String(), `<feed xmlns="http://www.w3.org/2005/Atom"`)
resp = MakeRequest(t, NewRequest(t, "GET", "/user2.rss"), http.StatusOK)
assert.Contains(t, resp.Body.String(), `<rss version="2.0"`)
var rss RSS
assert.NoError(t, xml.Unmarshal(resp.Body.Bytes(), &rss))
assert.Contains(t, rss.Channel.Link, "/user2")
assert.NotEmpty(t, rss.Channel.PubDate)
session := loginUser(t, "user2")
readUser := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser)
for _, tc := range []struct {
link, accept, token, privateMarker string
visible bool
}{
{"/user2.rss", "", readUser, "user2/repo2", true},
{"/user2", "application/rss+xml", readUser, "user2/repo2", true},
{"/user2.rss", "", getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopePublicOnly), "user2/repo2", false},
{"/org3.rss", "", getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadOrganization), "org3/repo3", true},
{"/user2?tab=activity", "", getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeAll), "user2/repo2", false},
} {
req := NewRequest(t, "GET", tc.link).SetHeader("Accept", tc.accept).AddBasicAuth("user2", tc.token)
assert.Equal(t, tc.visible, strings.Contains(MakeRequest(t, req, http.StatusOK).Body.String(), tc.privateMarker), tc.link)
}
readPackage := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadPackage)
for _, link := range []string{"/user2.rss", "/org3.rss"} {
MakeRequest(t, NewRequest(t, "GET", link).AddBasicAuth("user2", readPackage), http.StatusForbidden)
}
t.Run("Repo", func(t *testing.T) {
t.Run("RSS", func(t *testing.T) {
req := NewRequest(t, "GET", "/user2/repo1.rss")
@@ -117,32 +148,6 @@ func TestFeedUser(t *testing.T) {
}
})
// TestFeedUserPublicOnlyToken ensures a public-only API token cannot surface a user's
// private activity through their profile feed, even when authenticated as the owner.
t.Run("PublicOnlyToken", func(t *testing.T) {
fullToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser)
publicOnlyToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopePublicOnly)
for _, requestURL := range []string{"/user2.rss", "/user2?tab=activity"} {
t.Run(requestURL, func(t *testing.T) {
// user2 has activity on the private repo user2/repo2
const privateMarker = "user2/repo2"
// a normal read:user token authenticated as the owner sees private activity
reqFull := NewRequest(t, "GET", requestURL)
reqFull.SetBasicAuth("user2", fullToken)
respFull := MakeRequest(t, reqFull, http.StatusOK)
assert.Contains(t, respFull.Body.String(), privateMarker)
// a public-only token must not surface the private activity
reqPublicOnly := NewRequest(t, "GET", requestURL)
reqPublicOnly.SetBasicAuth("user2", publicOnlyToken)
respPublicOnly := MakeRequest(t, reqPublicOnly, http.StatusOK)
assert.NotContains(t, respPublicOnly.Body.String(), privateMarker)
})
}
})
t.Run("RepoTagsWithoutReleasesAccess", func(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
require.NoError(t, repo_service.UpdateRepositoryUnits(t.Context(), repo, nil, []unit_model.Type{unit_model.TypeReleases}))