mirror of
https://github.com/go-gitea/gitea.git
synced 2026-11-01 00:38:10 +00:00
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:
11 files changed
+76
-104
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 != "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user