fix(release): gate draft release attachments on web download endpoints (#38318) (#38325)

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 <bircni@icloud.com>
This commit is contained in:
Giteabot
2026-07-03 11:34:37 -07:00
committed by GitHub
parent f7bc6b89c1
commit ab10e37acf
2 changed files with 21 additions and 0 deletions

View File

@@ -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() {

View File

@@ -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) {