From 33923a4d7c3c0d25d40373447088d234b4a1387b Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sat, 16 May 2026 07:50:41 -0700 Subject: [PATCH] fix(web): enforce token scopes on raw, media, and attachment downloads (#37698) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR tightens token-scope enforcement for non-API download endpoints in the web layer. What it changes: - require `read:repository` for repository content downloads served from web routes such as: - `/raw/...` - `/media/...` - enforce attachment-specific scopes in `ServeAttachment`: - issue / pull request attachments require `read:issue` - release attachments require `read:repository` - centralize token-scope checks for web handlers with a shared context helper - add matrix-style integration coverage for: - public and private repository content downloads - `blob`, `branch`, `tag`, and `commit` download routes - global and repo-scoped attachment routes - `public-only` token behavior on public vs private resources Why: API tokens and OAuth access tokens can be used on some non-API web endpoints. Before this change, those endpoints relied on repository visibility and unit permissions, but did not consistently enforce the token’s declared scope. That allowed scoped tokens to access resources beyond their intended category through web download routes. --------- Co-authored-by: silverwind Co-authored-by: Claude (Opus 4.7) Co-authored-by: Nicolas --- routers/web/repo/attachment.go | 28 ++++++- routers/web/repo/download.go | 22 ++++++ services/context/permission.go | 67 ++++++++--------- tests/integration/attachment_test.go | 107 +++++++++++++++++++++++++++ tests/integration/download_test.go | 98 ++++++++++++++++++++++++ 5 files changed, 286 insertions(+), 36 deletions(-) diff --git a/routers/web/repo/attachment.go b/routers/web/repo/attachment.go index bb2002521c..247f6d530b 100644 --- a/routers/web/repo/attachment.go +++ b/routers/web/repo/attachment.go @@ -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 { diff --git a/routers/web/repo/download.go b/routers/web/repo/download.go index 25166ea1d3..7c74987d0d 100644 --- a/routers/web/repo/download.go +++ b/routers/web/repo/download.go @@ -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) { diff --git a/services/context/permission.go b/services/context/permission.go index 16de86bbc6..996f24a1b4 100644 --- a/services/context/permission.go +++ b/services/context/permission.go @@ -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)...) } diff --git a/tests/integration/attachment_test.go b/tests/integration/attachment_test.go index a00ba98b96..7fee5d3b38 100644 --- a/tests/integration/attachment_test.go +++ b/tests/integration/attachment_test.go @@ -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 @@ -200,3 +210,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) + }) + } +} diff --git a/tests/integration/download_test.go b/tests/integration/download_test.go index 3e7be98b09..b55a5a89bb 100644 --- a/tests/integration/download_test.go +++ b/tests/integration/download_test.go @@ -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) + }) + } +}