Gitea: Token scope bypass on web archive download endpoint
Description
Gitea's /archive/* endpoint fails to validate OAuth2 token scope, allowing tokens with non-repository scopes to download full archives of private repositories.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Gitea's /archive/* endpoint fails to validate OAuth2 token scope, allowing tokens with non-repository scopes to download full archives of private repositories.
Vulnerability
The /archive/* web endpoint in Gitea (handled by repo.Download in routers/web/repo/repo.go:372) does not call checkDownloadTokenScope or CheckRepoScopedToken to validate OAuth2 token scopes. While other endpoints like /raw/* were fixed in PR #37698, the archive endpoint was overlooked. This affects all Gitea versions prior to the fix.
Exploitation
An attacker needs a personal access token with any non-repository scope (e.g., read:issue or read:misc) and read access to a private repository. They can then send a GET request to /{owner}/{private-repo}/archive/main.tar.gz and receive the full archive (200 OK) instead of a 403 Forbidden. The fix present in the API equivalent (/api/v1/repos/{owner}/{repo}/archive/*) does not apply to this web route.
Impact
Successful exploitation allows an attacker to download the entire repository archive (zip/tar.gz) of private repositories they have access to, including all branches and history. This constitutes a scope escalation, as tokens intended only for non-repository scopes can access full repository content, with higher impact than the previously fixed endpoints.
Mitigation
As of the publication date, no official patch has been released. The suggested fix is to add checkDownloadTokenScope(ctx) to the Download and InitiateDownload functions in routers/web/repo/repo.go. Users should revoke any personal access tokens with non-repository scopes that are not needed, or restrict token usage until a fix is deployed. [1][2]
AI Insight generated on Jun 16, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1Patches
133923a4d7c3cfix(web): enforce token scopes on raw, media, and attachment downloads (#37698)
5 files changed · +286 −36
routers/web/repo/attachment.go+25 −3 modified@@ -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 {
routers/web/repo/download.go+22 −0 modified@@ -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) {
services/context/permission.go+34 −33 modified@@ -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)...) }
tests/integration/attachment_test.go+107 −0 modified@@ -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) + }) + } +}
tests/integration/download_test.go+98 −0 modified@@ -7,13 +7,21 @@ 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" "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) + }) + } +}
Vulnerability mechanics
Root cause
"The `/archive/*` web endpoint does not call `checkDownloadTokenScope` or `CheckRepoScopedToken`, allowing tokens with non-repository scopes to download full repository archives."
Attack vector
An attacker who possesses a personal access token with any non-repository scope (e.g., `read:issue` or `read:misc`) can download the full repository archive (zip/tar.gz) of any private repository the token owner has access to [ref_id=1][ref_id=2]. The attacker simply sends a GET request to `/{owner}/{private-repo}/archive/main.tar.gz` with the scoped token. The endpoint serves the archive with HTTP 200 instead of rejecting it with 403. This is a scope-escalation vulnerability [CWE-284] because the token's declared scope is not enforced on the `/archive/*` web route.
Affected code
The `Download` function in `routers/web/repo/repo.go:372` and the `InitiateDownload` function are missing a call to `checkDownloadTokenScope`. The outer group middleware `reqUnitCodeReader` checks repository permission but does not enforce token scope. The `checkDownloadTokenScope` helper already exists in `routers/web/repo/download.go` (same package) but was not applied to the archive endpoint.
What the fix does
The patch [patch_id=6217054] added `checkDownloadTokenScope` calls to `SingleDownload`, `SingleDownloadOrLFS`, `DownloadByID`, and `DownloadByIDOrLFS` in `routers/web/repo/download.go`, and centralized the token-scope logic into a shared `CheckTokenScopes` helper in `services/context/permission.go`. However, the advisory notes that the `/archive/*` endpoint (`repo.Download` in `routers/web/repo/repo.go:372`) was **not** included in this patch [ref_id=1][ref_id=2]. The suggested fix is to add `checkDownloadTokenScope(ctx)` to `Download` and `InitiateDownload` in `routers/web/repo/repo.go`, reusing the same function already defined in the same package.
Preconditions
- authAttacker must have a personal access token or OAuth2 token that is scoped to a non-repository category (e.g., read:issue, read:misc).
- authThe token owner must have access to a private repository on the Gitea instance.
- configThe target repository must be private (public repositories are not affected by the scope escalation).
- networkThe attacker must be able to reach the Gitea web endpoint /{owner}/{repo}/archive/*.
Generated on Jun 17, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.