0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-05-22 06:23:30 +02:00

fix(web): enforce token scopes on raw, media, and attachment downloads (#37698) (#37733)

This commit is contained in:
Giteabot 2026-05-16 09:18:44 -07:00 committed by GitHub
parent ab0d52b4c7
commit 2965b0c08a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 286 additions and 36 deletions

View File

@ -6,6 +6,7 @@ package repo
import (
"net/http"
auth_model "code.gitea.io/gitea/models/auth"
issues_model "code.gitea.io/gitea/models/issues"
access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo"
@ -21,6 +22,17 @@ import (
repo_service "code.gitea.io/gitea/services/repository"
)
func attachmentReadScope(unitType unit.Type) (auth_model.AccessTokenScope, bool) {
switch unitType {
case unit.TypeIssues, unit.TypePullRequests:
return auth_model.AccessTokenScopeReadIssue, true
case unit.TypeReleases:
return auth_model.AccessTokenScopeReadRepository, true
default:
return "", false
}
}
// UploadIssueAttachment response for Issue/PR attachments
func UploadIssueAttachment(ctx *context.Context) {
uploadAttachment(ctx, ctx.Repo.Repository.ID, attachment.UploadAttachmentForIssue)
@ -150,9 +162,12 @@ func ServeAttachment(ctx *context.Context, uuid string) {
return
}
} else { // If we have the linked type, we need to check access
var perm access_model.Permission
if ctx.Repo.Repository == nil {
repo, err := repo_model.GetRepositoryByID(ctx, repoID)
var (
perm access_model.Permission
repo = ctx.Repo.Repository
)
if repo == nil {
repo, err = repo_model.GetRepositoryByID(ctx, repoID)
if err != nil {
ctx.ServerError("GetRepositoryByID", err)
return
@ -170,6 +185,13 @@ func ServeAttachment(ctx *context.Context, uuid string) {
ctx.HTTPError(http.StatusNotFound)
return
}
if requiredScope, ok := attachmentReadScope(unitType); ok {
context.CheckTokenScopes(ctx, repo, requiredScope)
if ctx.Written() {
return
}
}
}
if err := attach.IncreaseDownloadCount(ctx); err != nil {

View File

@ -7,6 +7,7 @@ package repo
import (
"time"
auth_model "code.gitea.io/gitea/models/auth"
git_model "code.gitea.io/gitea/models/git"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/httpcache"
@ -18,6 +19,11 @@ import (
"code.gitea.io/gitea/services/context"
)
func checkDownloadTokenScope(ctx *context.Context) bool {
context.CheckRepoScopedToken(ctx, ctx.Repo.Repository, auth_model.Read)
return !ctx.Written()
}
// ServeBlobOrLFS download a git.Blob redirecting to LFS if necessary
func ServeBlobOrLFS(ctx *context.Context, blob *git.Blob, lastModified *time.Time) error {
if httpcache.HandleGenericETagPrivateCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`, lastModified) {
@ -88,6 +94,10 @@ func getBlobForEntry(ctx *context.Context) (*git.Blob, *time.Time) {
// SingleDownload download a file by repos path
func SingleDownload(ctx *context.Context) {
if !checkDownloadTokenScope(ctx) {
return
}
blob, lastModified := getBlobForEntry(ctx)
if blob == nil {
return
@ -100,6 +110,10 @@ func SingleDownload(ctx *context.Context) {
// SingleDownloadOrLFS download a file by repos path redirecting to LFS if necessary
func SingleDownloadOrLFS(ctx *context.Context) {
if !checkDownloadTokenScope(ctx) {
return
}
blob, lastModified := getBlobForEntry(ctx)
if blob == nil {
return
@ -112,6 +126,10 @@ func SingleDownloadOrLFS(ctx *context.Context) {
// DownloadByID download a file by sha1 ID
func DownloadByID(ctx *context.Context) {
if !checkDownloadTokenScope(ctx) {
return
}
blob, err := ctx.Repo.GitRepo.GetBlob(ctx.PathParam("sha"))
if err != nil {
if git.IsErrNotExist(err) {
@ -128,6 +146,10 @@ func DownloadByID(ctx *context.Context) {
// DownloadByIDOrLFS download a file by sha1 ID taking account of LFS
func DownloadByIDOrLFS(ctx *context.Context) {
if !checkDownloadTokenScope(ctx) {
return
}
blob, err := ctx.Repo.GitRepo.GetBlob(ctx.PathParam("sha"))
if err != nil {
if git.IsErrNotExist(err) {

View File

@ -12,6 +12,39 @@ import (
"code.gitea.io/gitea/models/unit"
)
// CheckTokenScopes checks whether the authenticated API token contains any of the given scopes.
func CheckTokenScopes(ctx *Context, repo *repo_model.Repository, scopes ...auth_model.AccessTokenScope) {
if ctx.Data["IsApiToken"] != true {
return
}
scope, ok := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope)
if !ok {
return
}
publicOnly, err := scope.PublicOnly()
if err != nil {
ctx.ServerError("PublicOnly", err)
return
}
if publicOnly && repo != nil && repo.IsPrivate {
ctx.HTTPError(http.StatusForbidden)
return
}
scopeMatched, err := scope.HasAnyScope(scopes...)
if err != nil {
ctx.ServerError("HasAnyScope", err)
return
}
if !scopeMatched {
ctx.HTTPError(http.StatusForbidden)
}
}
// RequireRepoAdmin returns a middleware for requiring repository admin permission
func RequireRepoAdmin() func(ctx *Context) {
return func(ctx *Context) {
@ -59,37 +92,5 @@ func RequireUnitReader(unitTypes ...unit.Type) func(ctx *Context) {
// CheckRepoScopedToken checks whether the authenticated API token has repo scope.
func CheckRepoScopedToken(ctx *Context, repo *repo_model.Repository, level auth_model.AccessTokenScopeLevel) {
if ctx.Data["IsApiToken"] != true {
return
}
scope, ok := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope)
if ok {
var scopeMatched bool
requiredScopes := auth_model.GetRequiredScopes(level, auth_model.AccessTokenScopeCategoryRepository)
// check if scope only applies to public resources
publicOnly, err := scope.PublicOnly()
if err != nil {
ctx.ServerError("HasScope", err)
return
}
if publicOnly && repo != nil && repo.IsPrivate {
ctx.HTTPError(http.StatusForbidden)
return
}
scopeMatched, err = scope.HasScope(requiredScopes...)
if err != nil {
ctx.ServerError("HasScope", err)
return
}
if !scopeMatched {
ctx.HTTPError(http.StatusForbidden)
return
}
}
CheckTokenScopes(ctx, repo, auth_model.GetRequiredScopes(level, auth_model.AccessTokenScopeCategoryRepository)...)
}

View File

@ -14,6 +14,7 @@ import (
"strings"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/test"
@ -26,6 +27,15 @@ import (
"github.com/stretchr/testify/require"
)
type attachmentScopeCase struct {
name string
url string
readIssueStatus int
readRepoStatus int
publicOnlyIssueStatus int
publicOnlyRepoStatus int
}
func testGeneratePngBytes() []byte {
myImage := image.NewRGBA(image.Rect(0, 0, 32, 32))
var buff bytes.Buffer
@ -201,3 +211,100 @@ func testDeleteAttachmentPermissions(t *testing.T) {
// test deleting release attachment from another repo
testDeleteReleaseAttachment(t, ownerSession, "user2/repo2", crossRepoUUID, http.StatusBadRequest)
}
func TestAttachmentTokenScopes(t *testing.T) {
defer tests.PrepareTestEnv(t)()
for _, uuid := range []string{
"a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11",
"a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12",
"a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a19",
"a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a22",
} {
_, err := storage.Attachments.Save(repo_model.AttachmentRelativePath(uuid), strings.NewReader("hello world"), -1)
require.NoError(t, err)
}
readIssueToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadIssue)
readRepoToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository)
miscToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadMisc)
publicOnlyIssueToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadIssue, auth_model.AccessTokenScopePublicOnly)
publicOnlyRepoToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository, auth_model.AccessTokenScopePublicOnly)
cases := []attachmentScopeCase{
{
name: "GlobalPublicIssueAttachment",
url: "/attachments/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11",
readIssueStatus: http.StatusOK,
readRepoStatus: http.StatusForbidden,
publicOnlyIssueStatus: http.StatusOK,
publicOnlyRepoStatus: http.StatusForbidden,
},
{
name: "RepoPublicIssueAttachment",
url: "/user2/repo1/attachments/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11",
readIssueStatus: http.StatusOK,
readRepoStatus: http.StatusForbidden,
publicOnlyIssueStatus: http.StatusOK,
publicOnlyRepoStatus: http.StatusForbidden,
},
{
name: "GlobalPrivateIssueAttachment",
url: "/attachments/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12",
readIssueStatus: http.StatusOK,
readRepoStatus: http.StatusForbidden,
publicOnlyIssueStatus: http.StatusForbidden,
publicOnlyRepoStatus: http.StatusForbidden,
},
{
name: "RepoPrivateIssueAttachment",
url: "/user2/repo2/attachments/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12",
readIssueStatus: http.StatusOK,
readRepoStatus: http.StatusForbidden,
publicOnlyIssueStatus: http.StatusForbidden,
publicOnlyRepoStatus: http.StatusForbidden,
},
{
name: "GlobalPublicReleaseAttachment",
url: "/attachments/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a19",
readIssueStatus: http.StatusForbidden,
readRepoStatus: http.StatusOK,
publicOnlyIssueStatus: http.StatusForbidden,
publicOnlyRepoStatus: http.StatusOK,
},
{
name: "RepoPublicReleaseAttachment",
url: "/user2/repo1/releases/attachments/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a19",
readIssueStatus: http.StatusForbidden,
readRepoStatus: http.StatusOK,
publicOnlyIssueStatus: http.StatusForbidden,
publicOnlyRepoStatus: http.StatusOK,
},
{
name: "GlobalPrivateReleaseAttachment",
url: "/attachments/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a22",
readIssueStatus: http.StatusForbidden,
readRepoStatus: http.StatusOK,
publicOnlyIssueStatus: http.StatusForbidden,
publicOnlyRepoStatus: http.StatusForbidden,
},
{
name: "RepoPrivateReleaseAttachment",
url: "/user2/repo2/releases/attachments/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a22",
readIssueStatus: http.StatusForbidden,
readRepoStatus: http.StatusOK,
publicOnlyIssueStatus: http.StatusForbidden,
publicOnlyRepoStatus: http.StatusForbidden,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
MakeRequest(t, NewRequest(t, "GET", tc.url).AddTokenAuth(miscToken), http.StatusForbidden)
MakeRequest(t, NewRequest(t, "GET", tc.url).AddTokenAuth(readIssueToken), tc.readIssueStatus)
MakeRequest(t, NewRequest(t, "GET", tc.url).AddTokenAuth(readRepoToken), tc.readRepoStatus)
MakeRequest(t, NewRequest(t, "GET", tc.url).AddTokenAuth(publicOnlyIssueToken), tc.publicOnlyIssueStatus)
MakeRequest(t, NewRequest(t, "GET", tc.url).AddTokenAuth(publicOnlyRepoToken), tc.publicOnlyRepoStatus)
})
}
}

View File

@ -7,6 +7,7 @@ import (
"net/http"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/tests"
@ -14,6 +15,13 @@ import (
"github.com/stretchr/testify/assert"
)
type downloadScopeCase struct {
name string
url string
withScope int
publicOnlyOK bool
}
func TestDownloadRepoContent(t *testing.T) {
defer tests.PrepareTestEnv(t)()
@ -71,3 +79,93 @@ func TestDownloadRepoContent(t *testing.T) {
assert.Equal(t, "application/xml", resp.Header().Get("Content-Type"))
})
}
func TestDownloadRepoContentTokenScopes(t *testing.T) {
defer tests.PrepareTestEnv(t)()
ownerReadToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository)
miscToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadMisc)
publicOnlyToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository, auth_model.AccessTokenScopePublicOnly)
cases := []downloadScopeCase{
{
name: "PublicRawBlob",
url: "/user2/repo1/raw/blob/4b4851ad51df6a7d9f25c979345979eaeb5b349f",
withScope: http.StatusOK,
publicOnlyOK: true,
},
{
name: "PublicRawBranch",
url: "/user2/repo1/raw/branch/master/README.md",
withScope: http.StatusOK,
publicOnlyOK: true,
},
{
name: "PublicRawTag",
url: "/user2/repo1/raw/tag/v1.1/README.md",
withScope: http.StatusOK,
publicOnlyOK: true,
},
{
name: "PublicRawCommit",
url: "/user2/repo1/raw/commit/65f1bf27bc3bf70f64657658635e66094edbcb4d/README.md",
withScope: http.StatusOK,
publicOnlyOK: true,
},
{
name: "PublicMediaBlob",
url: "/user2/repo1/media/blob/4b4851ad51df6a7d9f25c979345979eaeb5b349f",
withScope: http.StatusOK,
publicOnlyOK: true,
},
{
name: "PublicMediaBranch",
url: "/user2/repo1/media/branch/master/README.md",
withScope: http.StatusOK,
publicOnlyOK: true,
},
{
name: "PublicMediaTag",
url: "/user2/repo1/media/tag/v1.1/README.md",
withScope: http.StatusOK,
publicOnlyOK: true,
},
{
name: "PublicMediaCommit",
url: "/user2/repo1/media/commit/65f1bf27bc3bf70f64657658635e66094edbcb4d/README.md",
withScope: http.StatusOK,
publicOnlyOK: true,
},
{
name: "PrivateRawBranch",
url: "/user2/repo2/raw/branch/master/test.xml",
withScope: http.StatusOK,
publicOnlyOK: false,
},
{
name: "PrivateRawBlob",
url: "/user2/repo2/raw/blob/6395b68e1feebb1e4c657b4f9f6ba2676a283c0b",
withScope: http.StatusOK,
publicOnlyOK: false,
},
{
name: "PrivateMediaBranch",
url: "/user2/repo2/media/branch/master/test.xml",
withScope: http.StatusOK,
publicOnlyOK: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
MakeRequest(t, NewRequest(t, "GET", tc.url).AddTokenAuth(miscToken), http.StatusForbidden)
MakeRequest(t, NewRequest(t, "GET", tc.url).AddTokenAuth(ownerReadToken), tc.withScope)
publicOnlyStatus := http.StatusForbidden
if tc.publicOnlyOK {
publicOnlyStatus = tc.withScope
}
MakeRequest(t, NewRequest(t, "GET", tc.url).AddTokenAuth(publicOnlyToken), publicOnlyStatus)
})
}
}