From ab10e37acf7fabf7829a485cc3e13d118638a856 Mon Sep 17 00:00:00 2001 From: Giteabot Date: Fri, 3 Jul 2026 11:34:37 -0700 Subject: [PATCH] fix(release): gate draft release attachments on web download endpoints (#38318) (#38325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport #38318 by @bircni Draft-release access control was enforced only on the API release endpoints (`/api/v1/repos/{owner}/{repo}/releases/...`) but not on the UUID-based web attachment endpoints (`/attachments/{uuid}`, `/{owner}/{repo}/attachments/{uuid}`, `/{owner}/{repo}/releases/attachments/{uuid}`). Anyone who obtained an attachment UUID — including unauthenticated callers — could download files belonging to a hidden draft release, since `ServeAttachment` only checked repo-level read permission and never the release's draft state. This extends `ServeAttachment` to require write access to releases when the attachment belongs to a draft release, mirroring the existing API-side `canAccessReleaseDraft` gate. A regression test is included. Co-authored-by: bircni --- routers/web/repo/attachment.go | 16 ++++++++++++++++ tests/integration/attachment_test.go | 5 +++++ 2 files changed, 21 insertions(+) diff --git a/routers/web/repo/attachment.go b/routers/web/repo/attachment.go index 6f2968691c..5e0e4b62c1 100644 --- a/routers/web/repo/attachment.go +++ b/routers/web/repo/attachment.go @@ -186,6 +186,22 @@ func ServeAttachment(ctx *context.Context, uuid string) { return } + // Draft release attachments must not be exposed to anyone without write + // access, matching the API-side canAccessReleaseDraft gate. Otherwise the + // UUID-based web endpoints would leak draft attachments to any recipient of + // the (leaked) download URL. + if unitType == unit.TypeReleases && attach.ReleaseID != 0 && !perm.CanWrite(unit.TypeReleases) { + rel, err := repo_model.GetReleaseByID(ctx, attach.ReleaseID) + if err != nil { + ctx.ServerError("GetReleaseByID", err) + return + } + if rel.IsDraft { + ctx.HTTPError(http.StatusNotFound) + return + } + } + if requiredScope, ok := attachmentReadScope(unitType); ok { context.CheckTokenScopes(ctx, repo, requiredScope) if ctx.Written() { diff --git a/tests/integration/attachment_test.go b/tests/integration/attachment_test.go index cb97d3c692..f118f11957 100644 --- a/tests/integration/attachment_test.go +++ b/tests/integration/attachment_test.go @@ -171,6 +171,11 @@ func testGetAttachment(t *testing.T) { {"PrivateAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, user2Session, http.StatusOK}, {"RepoNotAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, user8Session, http.StatusNotFound}, {"OrgNotAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a21", true, user8Session, http.StatusNotFound}, + // draft release attachments must only be reachable by users with write access, even on a public repo + {"DraftReleaseByOwner", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a23", true, user2Session, http.StatusOK}, + {"DraftReleaseByAdmin", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a23", true, adminSession, http.StatusOK}, + {"DraftReleaseByNonCollaborator", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a23", true, user8Session, http.StatusNotFound}, + {"DraftReleaseByAnonymous", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a23", true, emptySession, http.StatusNotFound}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) {