VYPR
Medium severity5.3GHSA Advisory· Published Jun 16, 2026· Updated Jun 16, 2026

Gitea: Token scope bypass on web archive download endpoint

CVE-2026-20706

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

1

Patches

1
33923a4d7c3c

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

https://github.com/go-gitea/giteaLunny XiaoMay 16, 2026via body-scan-shorthand
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

2

News mentions

0

No linked articles in our index yet.