diff --git a/routers/web/repo/attachment.go b/routers/web/repo/attachment.go index 6f2968691c3..5e0e4b62c14 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 cb97d3c692c..f118f119574 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) {