Merge remote-tracking branch 'origin/main' into various-sec-fixes

# Conflicts:
#	routers/api/v1/api.go
This commit is contained in:
Nicolas
2026-06-15 22:12:27 +02:00
100 changed files with 3343 additions and 704 deletions
+93 -32
View File
@@ -88,6 +88,7 @@ import (
"gitea.dev/routers/api/v1/packages"
"gitea.dev/routers/api/v1/repo"
"gitea.dev/routers/api/v1/settings"
"gitea.dev/routers/api/v1/token"
"gitea.dev/routers/api/v1/user"
"gitea.dev/routers/common"
"gitea.dev/services/actions"
@@ -519,41 +520,79 @@ func reqOrgVisible() func(ctx *context.APIContext) {
}
}
// reqTeamMembership user should be an team member, or a site admin
func reqTeamMembership() func(ctx *context.APIContext) {
func teamAccessPrivileged(ctx *context.APIContext) (orgID int64, privileged, ok bool) {
if ctx.IsUserSiteAdmin() {
return 0, true, true
}
if ctx.Org.Team == nil {
setting.PanicInDevOrTesting("teamAccess: unprepared context")
ctx.APIErrorInternal(errors.New("teamAccess: unprepared context"))
return 0, false, false
}
orgID = ctx.Org.Team.OrgID
isOwner, err := organization.IsOrganizationOwner(ctx, orgID, ctx.Doer.ID)
if err != nil {
ctx.APIErrorInternal(err)
return 0, false, false
} else if isOwner {
return orgID, true, true
}
isTeamMember, err := organization.IsTeamMember(ctx, orgID, ctx.Org.Team.ID, ctx.Doer.ID)
if err != nil {
ctx.APIErrorInternal(err)
return 0, false, false
}
return orgID, isTeamMember, true
}
func denyNonTeamMember(ctx *context.APIContext, orgID int64) {
isOrgMember, err := organization.IsOrganizationMember(ctx, orgID, ctx.Doer.ID)
if err != nil {
ctx.APIErrorInternal(err)
} else if isOrgMember {
ctx.APIError(http.StatusForbidden, "Must be a team member")
} else {
ctx.APIErrorNotFound()
}
}
// reqTeamReadAccess allows callers who can list the team to read its metadata.
// Non-members are admitted by the team's visibility tier and parent org visibility.
// Not sufficient for mutations — use reqOrgOwnership() or reqTeamMembership() for those.
func reqTeamReadAccess() func(ctx *context.APIContext) {
return func(ctx *context.APIContext) {
if ctx.IsUserSiteAdmin() {
orgID, privileged, ok := teamAccessPrivileged(ctx)
if !ok || privileged {
return
}
if ctx.Org.Team == nil {
setting.PanicInDevOrTesting("reqTeamMembership: unprepared context")
ctx.APIErrorInternal(errors.New("reqTeamMembership: unprepared context"))
if ctx.Org.Organization == nil {
setting.PanicInDevOrTesting("reqTeamReadAccess: organization not loaded")
ctx.APIErrorInternal(errors.New("reqTeamReadAccess: organization not loaded"))
return
}
orgID := ctx.Org.Team.OrgID
isOwner, err := organization.IsOrganizationOwner(ctx, orgID, ctx.Doer.ID)
visible, err := ctx.Org.Team.CanNonMemberReadMeta(ctx, ctx.Org.Organization.AsUser(), ctx.Doer)
if err != nil {
ctx.APIErrorInternal(err)
return
} else if isOwner {
return
}
if !visible {
// Not admitted by visibility: 403 for org members, 404 otherwise.
denyNonTeamMember(ctx, orgID)
}
}
}
if isTeamMember, err := organization.IsTeamMember(ctx, orgID, ctx.Org.Team.ID, ctx.Doer.ID); err != nil {
ctx.APIErrorInternal(err)
return
} else if !isTeamMember {
isOrgMember, err := organization.IsOrganizationMember(ctx, orgID, ctx.Doer.ID)
if err != nil {
ctx.APIErrorInternal(err)
} else if isOrgMember {
ctx.APIError(http.StatusForbidden, "Must be a team member")
} else {
ctx.APIErrorNotFound()
}
// reqTeamMembership user should be a team member, or a site admin
func reqTeamMembership() func(ctx *context.APIContext) {
return func(ctx *context.APIContext) {
orgID, privileged, ok := teamAccessPrivileged(ctx)
if !ok || privileged {
return
}
denyNonTeamMember(ctx, orgID)
}
}
@@ -663,6 +702,17 @@ func orgAssignment(args ...bool) func(ctx *context.APIContext) {
}
return
}
if ctx.Org.Organization == nil {
ctx.Org.Organization, err = organization.GetOrgByID(ctx, ctx.Org.Team.OrgID)
if err != nil {
if organization.IsErrOrgNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return
}
}
}
}
}
@@ -991,6 +1041,11 @@ func Routes() *web.Router {
})
})
// Token introspection and deletion endpoint
m.Combo("/token").
Get(reqToken(), token.GetCurrentToken).
Delete(reqToken(), token.DeleteCurrentToken)
// Notifications (requires 'notifications' scope)
// The notifications API is not available for public-only tokens because a user's notifications mix
// public and private repository events in the same mailbox.
@@ -1712,25 +1767,31 @@ func Routes() *web.Router {
}, reqToken(), reqOrgOwnership())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true), checkTokenPublicOnly())
m.Group("/teams/{teamid}", func() {
m.Combo("").Get(reqToken(), org.GetTeam).
Patch(reqToken(), reqOrgOwnership(), bind(api.EditTeamOption{}), org.EditTeam).
m.Combo("").Patch(reqToken(), reqOrgOwnership(), bind(api.EditTeamOption{}), org.EditTeam).
Delete(reqToken(), reqOrgOwnership(), org.DeleteTeam)
m.Group("", func() {
m.Get("", org.GetTeam)
m.Group("/members", func() {
m.Get("", reqOrgMembership(), org.GetTeamMembers)
m.Combo("/{username}").Get(reqOrgMembership(), org.GetTeamMember)
})
m.Group("/repos", func() {
m.Get("", org.GetTeamRepos)
m.Combo("/{org}/{reponame}").Get(org.GetTeamRepo)
})
m.Get("/activities/feeds", org.ListTeamActivityFeeds)
}, reqTeamReadAccess())
m.Group("/members", func() {
m.Get("", reqToken(), org.GetTeamMembers)
m.Combo("/{username}").
Get(reqToken(), org.GetTeamMember).
Put(reqToken(), reqOrgOwnership(), org.AddTeamMember).
Delete(reqToken(), reqOrgOwnership(), org.RemoveTeamMember)
})
m.Group("/repos", func() {
m.Get("", reqToken(), org.GetTeamRepos)
m.Combo("/{org}/{reponame}").
Put(reqToken(), org.AddTeamRepository).
Delete(reqToken(), org.RemoveTeamRepository).
Get(reqToken(), org.GetTeamRepo)
Put(reqToken(), reqTeamMembership(), org.AddTeamRepository).
Delete(reqToken(), reqTeamMembership(), org.RemoveTeamRepository)
})
m.Get("/activities/feeds", org.ListTeamActivityFeeds)
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), reqTeamMembership(), checkTokenPublicOnly())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), checkTokenPublicOnly())
m.Group("/admin", func() {
m.Group("/cron", func() {
+30 -17
View File
@@ -55,10 +55,15 @@ func ListTeams(ctx *context.APIContext) {
// "$ref": "#/responses/notFound"
listOptions := utils.GetListOptions(ctx)
teams, count, err := organization.SearchTeam(ctx, &organization.SearchTeamOptions{
opts := &organization.SearchTeamOptions{
ListOptions: listOptions,
OrgID: ctx.Org.Organization.ID,
})
}
if err := organization.ApplyTeamListFilter(ctx, ctx.Org.Organization.ID, ctx.Doer, ctx.IsSigned, opts); err != nil {
ctx.APIErrorInternal(err)
return
}
teams, count, err := organization.SearchTeam(ctx, opts)
if err != nil {
ctx.APIErrorInternal(err)
return
@@ -218,6 +223,7 @@ func CreateTeam(ctx *context.APIContext) {
IncludesAllRepositories: form.IncludesAllRepositories,
CanCreateOrgRepo: form.CanCreateOrgRepo,
AccessMode: teamPermission,
Visibility: organization.NormalizeTeamVisibility(string(form.Visibility)),
}
if team.AccessMode < perm.AccessModeAdmin {
@@ -295,6 +301,10 @@ func EditTeam(ctx *context.APIContext) {
team.Description = *form.Description
}
if form.Visibility != nil && !team.IsOwnerTeam() {
team.Visibility = organization.NormalizeTeamVisibility(string(*form.Visibility))
}
isAuthChanged := false
isIncludeAllChanged := false
if !team.IsOwnerTeam() && len(form.Permission) != 0 {
@@ -387,15 +397,6 @@ func GetTeamMembers(ctx *context.APIContext) {
// "404":
// "$ref": "#/responses/notFound"
isMember, err := organization.IsOrganizationMember(ctx, ctx.Org.Team.OrgID, ctx.Doer.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
} else if !isMember && !ctx.Doer.IsAdmin {
ctx.APIErrorNotFound()
return
}
listOptions := utils.GetListOptions(ctx)
teamMembers, err := organization.GetTeamMembers(ctx, &organization.SearchMembersOptions{
ListOptions: listOptions,
@@ -574,14 +575,20 @@ func GetTeamRepos(ctx *context.APIContext) {
ctx.APIErrorInternal(err)
return
}
repos := make([]*api.Repository, len(teamRepos))
for i, repo := range teamRepos {
repos := make([]*api.Repository, 0, len(teamRepos))
for _, repo := range teamRepos {
permission, err := access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer)
if err != nil {
ctx.APIErrorInternal(err)
return
}
repos[i] = convert.ToRepo(ctx, repo, permission)
// A team's repo list is reachable by non-team-members through the team's
// visibility tier, so never expose repos (incl. their names) the doer
// cannot access.
if !permission.HasAnyUnitAccessOrPublicAccess() {
continue
}
repos = append(repos, convert.ToRepo(ctx, repo, permission))
}
ctx.SetLinkHeader(int64(team.NumRepos), listOptions.PageSize)
ctx.SetTotalCountHeader(int64(team.NumRepos))
@@ -633,6 +640,12 @@ func GetTeamRepo(ctx *context.APIContext) {
ctx.APIErrorInternal(err)
return
}
// The team may be reachable by a non-team-member via its visibility tier;
// don't confirm the existence of a repo the doer cannot access.
if !permission.HasAnyUnitAccessOrPublicAccess() {
ctx.APIErrorNotFound()
return
}
ctx.JSON(http.StatusOK, convert.ToRepo(ctx, repo, permission))
}
@@ -806,9 +819,9 @@ func SearchTeam(ctx *context.APIContext) {
ListOptions: listOptions,
}
// Only admin is allowed to search for all teams
if !ctx.Doer.IsAdmin {
opts.UserID = ctx.Doer.ID
if err := organization.ApplyTeamListFilter(ctx, ctx.Org.Organization.ID, ctx.Doer, ctx.IsSigned, opts); err != nil {
ctx.APIErrorInternal(err)
return
}
teams, maxResults, err := organization.SearchTeam(ctx, opts)
+40 -1
View File
@@ -11,6 +11,7 @@ import (
api "gitea.dev/modules/structs"
"gitea.dev/services/context"
"gitea.dev/services/convert"
git_service "gitea.dev/services/git"
)
// CompareDiff compare two branches or commits
@@ -18,8 +19,12 @@ func CompareDiff(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/compare/{basehead} repository repoCompareDiff
// ---
// summary: Get commit comparison information
// description: |
// By default returns JSON commit comparison information. The raw diff or patch can be
// requested with the `output` query parameter set to `diff` or `patch` respectively.
// produces:
// - application/json
// - text/plain
// parameters:
// - name: owner
// in: path
@@ -33,9 +38,16 @@ func CompareDiff(ctx *context.APIContext) {
// required: true
// - name: basehead
// in: path
// description: compare two branches or commits
// description: compare two refs as `base...head` (or `base..head`); refs may be branches, tags, full or short SHAs, including branch names that contain slashes.
// type: string
// required: true
// - name: output
// in: query
// description: return the raw comparison as `diff` or `patch` instead of JSON
// type: string
// enum:
// - diff
// - patch
// responses:
// "200":
// "$ref": "#/responses/Compare"
@@ -57,6 +69,16 @@ func CompareDiff(ctx *context.APIContext) {
}
defer closer()
// ?output=diff|patch returns the raw output, otherwise the JSON comparison is returned.
switch ctx.FormString("output") {
case "diff":
downloadCompareDiffOrPatch(ctx, compareInfo, false)
return
case "patch":
downloadCompareDiffOrPatch(ctx, compareInfo, true)
return
}
verification := ctx.FormString("verification") == "" || ctx.FormBool("verification")
files := ctx.FormString("files") == "" || ctx.FormBool("files")
@@ -88,3 +110,20 @@ func CompareDiff(ctx *context.APIContext) {
Commits: apiCommits,
})
}
// downloadCompareDiffOrPatch writes a comparison's raw diff or patch to the response.
func downloadCompareDiffOrPatch(ctx *context.APIContext, compareInfo *git_service.CompareInfo, patch bool) {
ctx.Resp.Header().Set("Content-Type", "text/plain; charset=utf-8")
compareArg := compareInfo.BaseCommitID + compareInfo.CompareSeparator + compareInfo.HeadCommitID
var err error
if patch {
err = compareInfo.HeadGitRepo.GetPatch(compareArg, ctx.Resp)
} else {
err = compareInfo.HeadGitRepo.GetDiff(compareArg, ctx.Resp)
}
if err != nil {
ctx.APIErrorInternal(err)
return
}
}
@@ -91,6 +91,7 @@ func ListTrackedTimes(ctx *context.APIContext) {
user, err := user_model.GetUserByName(ctx, qUser)
if user_model.IsErrUserNotExist(err) {
ctx.APIError(http.StatusNotFound, err.Error())
return
} else if err != nil {
ctx.APIErrorInternal(err)
return
@@ -499,6 +500,7 @@ func ListTrackedTimesByRepository(ctx *context.APIContext) {
user, err := user_model.GetUserByName(ctx, qUser)
if user_model.IsErrUserNotExist(err) {
ctx.APIError(http.StatusNotFound, err.Error())
return
} else if err != nil {
ctx.APIErrorInternal(err)
return
+7
View File
@@ -20,3 +20,10 @@ type swaggerResponseAccessToken struct {
// in:body
Body api.AccessToken `json:"body"`
}
// CurrentAccessToken represents the currently authenticated access token.
// swagger:response CurrentAccessToken
type swaggerResponseCurrentAccessToken struct {
// in:body
Body api.CurrentAccessToken `json:"body"`
}
+88
View File
@@ -0,0 +1,88 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package token
import (
"errors"
"net/http"
auth_model "gitea.dev/models/auth"
user_model "gitea.dev/models/user"
"gitea.dev/modules/auth/httpauth"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
"gitea.dev/services/context"
)
// GetCurrentToken returns metadata about the currently authenticated token.
func GetCurrentToken(ctx *context.APIContext) {
// swagger:operation GET /token miscellaneous getCurrentToken
// ---
// summary: Get the currently authenticated token
// produces:
// - application/json
// responses:
// "200":
// "$ref": "#/responses/CurrentAccessToken"
accessToken, err := getToken(ctx)
if err != nil {
ctx.APIErrorAuto(err)
return
}
// Get user info
user, err := user_model.GetUserByID(ctx, accessToken.UID)
if err != nil {
ctx.APIErrorAuto(err)
return
}
ctx.JSON(http.StatusOK, &api.CurrentAccessToken{
ID: accessToken.ID,
Name: accessToken.Name,
Scopes: accessToken.Scope.StringSlice(),
CreatedAt: accessToken.CreatedUnix.AsTime(),
LastUsedAt: accessToken.UpdatedUnix.AsTime(),
User: &api.UserMeta{
ID: user.ID,
Login: user.Name,
},
})
}
// DeleteCurrentToken deletes the currently authenticated token.
func DeleteCurrentToken(ctx *context.APIContext) {
// swagger:operation DELETE /token miscellaneous deleteCurrentToken
// ---
// summary: Delete the currently authenticated token
// produces:
// - application/json
// responses:
// "204":
// description: token deleted
accessToken, err := getToken(ctx)
if err != nil {
ctx.APIErrorAuto(err)
return
}
// Delete the token
err = auth_model.DeleteAccessTokenByID(ctx, accessToken.ID, accessToken.UID)
if err != nil && !errors.Is(err, util.ErrNotExist) {
ctx.APIErrorAuto(err)
return
}
ctx.Status(http.StatusNoContent)
}
// getToken retrieves an access token from the API context's Authorization header and validates it against the database.
// Returns nil if the token is invalid and handles the response
func getToken(ctx *context.APIContext) (*auth_model.AccessToken, error) {
authHeader := ctx.Req.Header.Get("Authorization")
parsed, ok := httpauth.ParseAuthorizationHeader(authHeader)
if !ok || parsed.BearerToken == nil {
return nil, util.NewNotExistErrorf("invalid access token")
}
return auth_model.GetAccessTokenBySHA(ctx, parsed.BearerToken.Token)
}
+1 -9
View File
@@ -191,17 +191,9 @@ func DeleteAccessToken(ctx *context.APIContext) {
return
}
}
if tokenID == 0 {
ctx.APIErrorInternal(nil)
return
}
if err := auth_model.DeleteAccessTokenByID(ctx, tokenID, ctx.ContextUser.ID); err != nil {
if auth_model.IsErrAccessTokenNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
ctx.APIErrorAuto(err)
return
}