fix: harden context user access scope (#39945) (#40115)

Backport https://github.com/go-gitea/gitea/pull/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`.

`release/v28` lacks https://github.com/go-gitea/gitea/pull/39967, so
`/{username}` enables basic auth only for feed requests through a
middleware. The profile page token tests from
https://github.com/go-gitea/gitea/pull/39853 are folded into
`TestFeedUser`, because profile pages no longer accept tokens.

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
authored and GitHub committed 2026-10-31 17:40:49 +01:00
1 parent 6c8eaaf9e7
commit 5a3f06430d
11 files changed
+76 -104

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
@@ -70,7 +70,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
@@ -373,8 +373,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
@@ -317,8 +317,14 @@ 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) {
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
}
}
@@ -1162,8 +1168,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, checkTokenPublicOnly(), 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)
@@ -1283,7 +1292,7 @@ func Routes() *web.Router {
m.Delete("", user.UnblockUser)
}, context.UserAssignmentAPI(), checkTokenPublicOnly())
}, rejectPublicOnly())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken(), contextAuthenticatedUser(), checkTokenPublicOnly())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken(), contextRealDoerUser, checkTokenPublicOnly())
// Repositories (requires repo scope, org scope)
m.Post("/org/{org}/repos",
@@ -1780,7 +1789,6 @@ 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), checkTokenPublicOnly(), org.ListMyOrgs)
m.Group("/users/{username}/orgs", func() {
m.Get("", reqToken(), org.ListUserOrgs)
m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions)
+1
View File
@@ -129,6 +129,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,11 +7,13 @@ import (
"time"
activities_model "gitea.dev/models/activities"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/organization"
"gitea.dev/models/renderhelper"
user_model "gitea.dev/models/user"
"gitea.dev/modules/markup/markdown"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
"gitea.dev/services/context"
feed_service "gitea.dev/services/feed"
@@ -37,6 +39,10 @@ func showUserFeed(ctx *context.Context, formatType string) {
ctx.NotFound(nil)
return
}
context.CheckTokenScopes(ctx, nil, 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 && isOrganisation && !includePrivate {
+12 -2
View File
@@ -897,9 +897,19 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
}, adminReq, ctxDataSet(reqctx.ContextData{"EnableOAuth2": setting.OAuth2.Enabled, "EnablePackages": setting.Packages.Enabled}))
// ***** END: Admin *****
// tokens only authenticate feeds, the other variants render data outside any token scope
allowBasicForFeeds := types.PreMiddlewareProvider(func(next http.Handler) http.Handler {
nextWithBasic := webAuth.AllowBasic(next)
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if isFeed, _ := feed.GetFeedType(req.URL.Path, req); isFeed {
nextWithBasic.ServeHTTP(w, req)
} else {
next.ServeHTTP(w, req)
}
})
})
m.Group("", func() {
// it handles "username.rss" in the handler, so allow basic auth as other rss/atom routes
m.Get("/{username}", webAuth.AllowBasic, user.UsernameSubRoute)
m.Get("/{username}", allowBasicForFeeds, user.UsernameSubRoute)
m.Methods("GET, OPTIONS", "/attachments/{uuid}", optionsCorsHandler(), webAuth.AllowBasic, webAuth.AllowOAuth2, repo.GetAttachment)
}, optSignIn)
@@ -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)
}
+28 -92
View File
@@ -6,6 +6,7 @@ package integration
import (
"encoding/xml"
"net/http"
"strings"
"testing"
auth_model "gitea.dev/models/auth"
@@ -31,103 +32,38 @@ type RSS struct {
}
func TestFeedUser(t *testing.T) {
t.Run("User", func(t *testing.T) {
t.Run("Atom", func(t *testing.T) {
defer tests.PrepareTestEnv(t)()
req := NewRequest(t, "GET", "/user2.atom")
resp := MakeRequest(t, req, http.StatusOK)
data := resp.Body.String()
assert.Contains(t, data, `<feed xmlns="http://www.w3.org/2005/Atom"`)
})
t.Run("RSS", func(t *testing.T) {
defer tests.PrepareTestEnv(t)()
req := NewRequest(t, "GET", "/user2.rss")
resp := MakeRequest(t, req, http.StatusOK)
data := resp.Body.String()
assert.Contains(t, data, `<rss version="2.0"`)
var rss RSS
err := xml.Unmarshal(resp.Body.Bytes(), &rss)
assert.NoError(t, err)
assert.Contains(t, rss.Channel.Link, "/user2")
assert.NotEmpty(t, rss.Channel.PubDate)
})
})
}
// TestFeedUserPublicOnlyToken ensures a public-only API token cannot surface a user's
// private activity through their profile feed, even when authenticated as the owner.
func TestFeedUserPublicOnlyToken(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// user2 has activity on the private repo user2/repo2
const privateMarker = "user2/repo2"
resp := MakeRequest(t, NewRequest(t, "GET", "/user2.atom"), http.StatusOK)
assert.Contains(t, resp.Body.String(), `<feed xmlns="http://www.w3.org/2005/Atom"`)
// a normal read:user token authenticated as the owner sees private activity
fullToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser)
reqFull := NewRequest(t, "GET", "/user2.rss")
reqFull.SetBasicAuth("user2", fullToken)
respFull := MakeRequest(t, reqFull, http.StatusOK)
assert.Contains(t, respFull.Body.String(), privateMarker)
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)
// a public-only token must not surface the private activity
publicOnlyToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopePublicOnly)
reqPublicOnly := NewRequest(t, "GET", "/user2.rss")
reqPublicOnly.SetBasicAuth("user2", publicOnlyToken)
respPublicOnly := MakeRequest(t, reqPublicOnly, http.StatusOK)
assert.NotContains(t, respPublicOnly.Body.String(), privateMarker)
}
// TestProfileActivityPublicOnlyToken ensures the HTML profile activity tab does not
// surface a user's private activity to a public-only API token, mirroring the RSS/Atom
// guard. The /{username} route is AllowBasic, so a public-only PAT used as the Basic
// password must still be downgraded even for the feed owner.
func TestProfileActivityPublicOnlyToken(t *testing.T) {
defer tests.PrepareTestEnv(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
fullToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser)
reqFull := NewRequest(t, "GET", "/user2?tab=activity")
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
publicOnlyToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopePublicOnly)
reqPublicOnly := NewRequest(t, "GET", "/user2?tab=activity")
reqPublicOnly.SetBasicAuth("user2", publicOnlyToken)
respPublicOnly := MakeRequest(t, reqPublicOnly, http.StatusOK)
assert.NotContains(t, respPublicOnly.Body.String(), privateMarker)
}
func TestProfileReposAndLimitedOwnersPublicOnlyToken(t *testing.T) {
defer tests.PrepareTestEnv(t)()
fullToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser)
publicOnlyToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopePublicOnly)
for link, privateRepoLink := range map[string]string{
"/user2?tab=repositories&q=repo2": `"/user2/repo2"`,
"/user2?tab=stars": `"/user2/repo2"`,
"/org3": `"/org3/repo3"`,
session := loginUser(t, "user2")
readUser := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser)
readUserPublicOnly := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopePublicOnly)
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", "", readUserPublicOnly, "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},
} {
reqFull := NewRequest(t, "GET", link)
reqFull.SetBasicAuth("user2", fullToken)
assert.Contains(t, MakeRequest(t, reqFull, http.StatusOK).Body.String(), privateRepoLink, link)
reqPublicOnly := NewRequest(t, "GET", link)
reqPublicOnly.SetBasicAuth("user2", publicOnlyToken)
assert.NotContains(t, MakeRequest(t, reqPublicOnly, http.StatusOK).Body.String(), privateRepoLink, link)
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)
}
for _, limitedLink := range []string{"/user33", "/user33.rss", "/limited_org"} {
assertBasicAuthStatus(t, limitedLink, fullToken, http.StatusOK)
assertBasicAuthStatus(t, limitedLink, publicOnlyToken, http.StatusNotFound)
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)
}
assertBasicAuthStatus(t, "/user33.rss", readUser, http.StatusOK)
assertBasicAuthStatus(t, "/user33.rss", readUserPublicOnly, http.StatusNotFound)
}