From e7fca90a780e4d35eb1fa67b1f377ebd54e74611 Mon Sep 17 00:00:00 2001 From: Giteabot Date: Mon, 23 Feb 2026 06:09:07 +0800 Subject: [PATCH] Fix get release draft permission check (#36659) (#36715) Backport #36659 by @lunny Draft release and it's attachments need a write permission to access. Signed-off-by: Lunny Xiao Co-authored-by: Lunny Xiao Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- models/fixtures/attachment.yml | 13 ++++ routers/api/v1/repo/release.go | 38 +++++++++- routers/api/v1/repo/release_attachment.go | 16 ++++ .../api_releases_attachment_test.go | 36 +++++++++ tests/integration/api_releases_test.go | 74 ++++++++++++++++++- 5 files changed, 174 insertions(+), 3 deletions(-) diff --git a/models/fixtures/attachment.yml b/models/fixtures/attachment.yml index 7882d8bff2..570d4a27da 100644 --- a/models/fixtures/attachment.yml +++ b/models/fixtures/attachment.yml @@ -153,3 +153,16 @@ download_count: 0 size: 0 created_unix: 946684800 + +- + id: 13 + uuid: a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a23 + repo_id: 1 + issue_id: 0 + release_id: 4 + uploader_id: 2 + comment_id: 0 + name: draft-attach + download_count: 0 + size: 0 + created_unix: 946684800 diff --git a/routers/api/v1/repo/release.go b/routers/api/v1/repo/release.go index 272b395dfb..4f17590abd 100644 --- a/routers/api/v1/repo/release.go +++ b/routers/api/v1/repo/release.go @@ -8,6 +8,7 @@ import ( "fmt" "net/http" + auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/perm" repo_model "code.gitea.io/gitea/models/repo" @@ -21,6 +22,28 @@ import ( release_service "code.gitea.io/gitea/services/release" ) +func hasRepoWriteScope(ctx *context.APIContext) bool { + scope, ok := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope) + if ctx.Data["IsApiToken"] != true || !ok { + return true + } + + requiredScopes := auth_model.GetRequiredScopes(auth_model.Write, auth_model.AccessTokenScopeCategoryRepository) + allow, err := scope.HasScope(requiredScopes...) + if err != nil { + ctx.APIError(http.StatusForbidden, "checking scope failed: "+err.Error()) + return false + } + return allow +} + +func canAccessDraftRelease(ctx *context.APIContext) bool { + if !ctx.IsSigned || !ctx.Repo.CanWrite(unit.TypeReleases) { + return false + } + return hasRepoWriteScope(ctx) +} + // GetRelease get a single release of a repository func GetRelease(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/releases/{id} repository repoGetRelease @@ -62,6 +85,15 @@ func GetRelease(ctx *context.APIContext) { return } + if release.IsDraft { // only the users with write access can see draft releases + if !canAccessDraftRelease(ctx) { + if !ctx.Written() { + ctx.APIErrorNotFound() + } + return + } + } + if err := release.LoadAttributes(ctx); err != nil { ctx.APIErrorInternal(err) return @@ -151,9 +183,13 @@ func ListReleases(ctx *context.APIContext) { // "$ref": "#/responses/notFound" listOptions := utils.GetListOptions(ctx) + includeDrafts := (ctx.Repo.AccessMode >= perm.AccessModeWrite || ctx.Repo.UnitAccessMode(unit.TypeReleases) >= perm.AccessModeWrite) && hasRepoWriteScope(ctx) + if ctx.Written() { + return + } opts := repo_model.FindReleasesOptions{ ListOptions: listOptions, - IncludeDrafts: ctx.Repo.AccessMode >= perm.AccessModeWrite || ctx.Repo.UnitAccessMode(unit.TypeReleases) >= perm.AccessModeWrite, + IncludeDrafts: includeDrafts, IncludeTags: false, IsDraft: ctx.FormOptionalBool("draft"), IsPreRelease: ctx.FormOptionalBool("pre-release"), diff --git a/routers/api/v1/repo/release_attachment.go b/routers/api/v1/repo/release_attachment.go index 5f5423fafe..e9b549be82 100644 --- a/routers/api/v1/repo/release_attachment.go +++ b/routers/api/v1/repo/release_attachment.go @@ -34,6 +34,14 @@ func checkReleaseMatchRepo(ctx *context.APIContext, releaseID int64) bool { ctx.APIErrorNotFound() return false } + if release.IsDraft { + if !canAccessDraftRelease(ctx) { + if !ctx.Written() { + ctx.APIErrorNotFound() + } + return false + } + } return true } @@ -141,6 +149,14 @@ func ListReleaseAttachments(ctx *context.APIContext) { ctx.APIErrorNotFound() return } + if release.IsDraft { + if !canAccessDraftRelease(ctx) { + if !ctx.Written() { + ctx.APIErrorNotFound() + } + return + } + } if err := release.LoadAttributes(ctx); err != nil { ctx.APIErrorInternal(err) return diff --git a/tests/integration/api_releases_attachment_test.go b/tests/integration/api_releases_attachment_test.go index 5df3042437..e859b23c72 100644 --- a/tests/integration/api_releases_attachment_test.go +++ b/tests/integration/api_releases_attachment_test.go @@ -13,8 +13,11 @@ import ( "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" ) func TestAPIEditReleaseAttachmentWithUnallowedFile(t *testing.T) { @@ -38,3 +41,36 @@ func TestAPIEditReleaseAttachmentWithUnallowedFile(t *testing.T) { session.MakeRequest(t, req, http.StatusUnprocessableEntity) } + +func TestAPIDraftReleaseAttachmentAccess(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 13}) + release := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{ID: attachment.ReleaseID}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: attachment.RepoID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + reader := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + listURL := fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets", repoOwner.Name, repo.Name, release.ID) + getURL := fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets/%d", repoOwner.Name, repo.Name, release.ID, attachment.ID) + + MakeRequest(t, NewRequest(t, "GET", listURL), http.StatusNotFound) + MakeRequest(t, NewRequest(t, "GET", getURL), http.StatusNotFound) + + readerToken := getUserToken(t, reader.LowerName, auth_model.AccessTokenScopeReadRepository) + MakeRequest(t, NewRequest(t, "GET", listURL).AddTokenAuth(readerToken), http.StatusNotFound) + MakeRequest(t, NewRequest(t, "GET", getURL).AddTokenAuth(readerToken), http.StatusNotFound) + + ownerReadToken := getUserToken(t, repoOwner.LowerName, auth_model.AccessTokenScopeReadRepository) + MakeRequest(t, NewRequest(t, "GET", listURL).AddTokenAuth(ownerReadToken), http.StatusNotFound) + MakeRequest(t, NewRequest(t, "GET", getURL).AddTokenAuth(ownerReadToken), http.StatusNotFound) + ownerToken := getUserToken(t, repoOwner.LowerName, auth_model.AccessTokenScopeWriteRepository) + resp := MakeRequest(t, NewRequest(t, "GET", listURL).AddTokenAuth(ownerToken), http.StatusOK) + var attachments []*api.Attachment + DecodeJSON(t, resp, &attachments) + if assert.Len(t, attachments, 1) { + assert.Equal(t, attachment.ID, attachments[0].ID) + } + + MakeRequest(t, NewRequest(t, "GET", getURL).AddTokenAuth(ownerToken), http.StatusOK) +} diff --git a/tests/integration/api_releases_test.go b/tests/integration/api_releases_test.go index b3b30a33d5..ea5e20a4fd 100644 --- a/tests/integration/api_releases_test.go +++ b/tests/integration/api_releases_test.go @@ -29,12 +29,12 @@ import ( "github.com/stretchr/testify/assert" ) -func TestAPIListReleases(t *testing.T) { +func TestAPIListReleasesWithWriteToken(t *testing.T) { defer tests.PrepareTestEnv(t)() repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) - token := getUserToken(t, user2.LowerName, auth_model.AccessTokenScopeReadRepository) + token := getUserToken(t, user2.LowerName, auth_model.AccessTokenScopeWriteRepository) link, _ := url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/releases", user2.Name, repo.Name)) resp := MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK) @@ -81,6 +81,76 @@ func TestAPIListReleases(t *testing.T) { testFilterByLen(true, url.Values{"draft": {"true"}, "pre-release": {"true"}}, 0, "there is no pre-release draft") } +func TestAPIListReleasesWithReadToken(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + token := getUserToken(t, user2.LowerName, auth_model.AccessTokenScopeReadRepository) + + link, _ := url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/releases", user2.Name, repo.Name)) + resp := MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK) + var apiReleases []*api.Release + DecodeJSON(t, resp, &apiReleases) + if assert.Len(t, apiReleases, 2) { + for _, release := range apiReleases { + switch release.ID { + case 1: + assert.False(t, release.IsDraft) + assert.False(t, release.IsPrerelease) + assert.True(t, strings.HasSuffix(release.UploadURL, "/api/v1/repos/user2/repo1/releases/1/assets"), release.UploadURL) + case 5: + assert.False(t, release.IsDraft) + assert.True(t, release.IsPrerelease) + assert.True(t, strings.HasSuffix(release.UploadURL, "/api/v1/repos/user2/repo1/releases/5/assets"), release.UploadURL) + default: + assert.NoError(t, fmt.Errorf("unexpected release: %v", release)) + } + } + } + + // test filter + testFilterByLen := func(auth bool, query url.Values, expectedLength int, msgAndArgs ...string) { + link.RawQuery = query.Encode() + req := NewRequest(t, "GET", link.String()) + if auth { + req.AddTokenAuth(token) + } + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiReleases) + assert.Len(t, apiReleases, expectedLength, msgAndArgs) + } + + testFilterByLen(false, url.Values{"draft": {"true"}}, 0, "anon should not see drafts") + testFilterByLen(true, url.Values{"draft": {"true"}}, 0, "repo owner with read token should not see drafts") + testFilterByLen(true, url.Values{"draft": {"false"}}, 2, "exclude drafts") + testFilterByLen(true, url.Values{"draft": {"false"}, "pre-release": {"false"}}, 1, "exclude drafts and pre-releases") + testFilterByLen(true, url.Values{"pre-release": {"true"}}, 1, "only get pre-release") + testFilterByLen(true, url.Values{"draft": {"true"}, "pre-release": {"true"}}, 0, "there is no pre-release draft") +} + +func TestAPIGetDraftRelease(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + release := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{ID: 4}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + reader := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d", owner.Name, repo.Name, release.ID) + + MakeRequest(t, NewRequest(t, "GET", urlStr), http.StatusNotFound) + + readerToken := getUserToken(t, reader.LowerName, auth_model.AccessTokenScopeReadRepository) + MakeRequest(t, NewRequest(t, "GET", urlStr).AddTokenAuth(readerToken), http.StatusNotFound) + + ownerToken := getUserToken(t, owner.LowerName, auth_model.AccessTokenScopeWriteRepository) + resp := MakeRequest(t, NewRequest(t, "GET", urlStr).AddTokenAuth(ownerToken), http.StatusOK) + var apiRelease api.Release + DecodeJSON(t, resp, &apiRelease) + assert.Equal(t, release.Title, apiRelease.Title) +} + func createNewReleaseUsingAPI(t *testing.T, token string, owner *user_model.User, repo *repo_model.Repository, name, target, title, desc string) *api.Release { urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/releases", owner.Name, repo.Name) req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateReleaseOption{