mirror of
https://github.com/go-gitea/gitea.git
synced 2026-11-02 13:44:16 +09:00
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:
11 files changed
+71
-39
No files matched your search
@@ -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 != "" {
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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}))
|
||||
|
||||
Reference in new issue
Block a user