mirror of
https://github.com/go-gitea/gitea.git
synced 2026-05-17 03:43:23 +02:00
This PR tightens token-scope enforcement for non-API download endpoints in the web layer. What it changes: - require `read:repository` for repository content downloads served from web routes such as: - `/raw/...` - `/media/...` - enforce attachment-specific scopes in `ServeAttachment`: - issue / pull request attachments require `read:issue` - release attachments require `read:repository` - centralize token-scope checks for web handlers with a shared context helper - add matrix-style integration coverage for: - public and private repository content downloads - `blob`, `branch`, `tag`, and `commit` download routes - global and repo-scoped attachment routes - `public-only` token behavior on public vs private resources Why: API tokens and OAuth access tokens can be used on some non-API web endpoints. Before this change, those endpoints relied on repository visibility and unit permissions, but did not consistently enforce the token’s declared scope. That allowed scoped tokens to access resources beyond their intended category through web download routes. --------- Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com> Co-authored-by: Nicolas <bircni@icloud.com>
310 lines
13 KiB
Go
310 lines
13 KiB
Go
// Copyright 2019 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package integration
|
|
|
|
import (
|
|
"bytes"
|
|
"image"
|
|
"image/png"
|
|
"io/fs"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"os"
|
|
"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"
|
|
"code.gitea.io/gitea/modules/web"
|
|
route_web "code.gitea.io/gitea/routers/web"
|
|
"code.gitea.io/gitea/services/context"
|
|
"code.gitea.io/gitea/tests"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"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
|
|
_ = png.Encode(&buff, myImage)
|
|
return buff.Bytes()
|
|
}
|
|
|
|
func testCreateIssueAttachment(t *testing.T, session *TestSession, repoURL, filename string, content []byte, expectedStatus int) string {
|
|
return testCreateAttachment(t, session, repoURL, "issues", filename, content, expectedStatus)
|
|
}
|
|
|
|
func testCreateReleaseAttachment(t *testing.T, session *TestSession, repoURL, filename string, content []byte, expectedStatus int) string {
|
|
return testCreateAttachment(t, session, repoURL, "releases", filename, content, expectedStatus)
|
|
}
|
|
|
|
func testCreateAttachment(t *testing.T, session *TestSession, repoURL, issueOrRelease, filename string, content []byte, expectedStatus int) string {
|
|
body := &bytes.Buffer{}
|
|
|
|
// Setup multi-part
|
|
writer := multipart.NewWriter(body)
|
|
part, err := writer.CreateFormFile("file", filename)
|
|
assert.NoError(t, err)
|
|
_, err = part.Write(content)
|
|
assert.NoError(t, err)
|
|
err = writer.Close()
|
|
assert.NoError(t, err)
|
|
|
|
req := NewRequestWithBody(t, "POST", repoURL+"/"+issueOrRelease+"/attachments", body)
|
|
req.Header.Add("Content-Type", writer.FormDataContentType())
|
|
resp := session.MakeRequest(t, req, expectedStatus)
|
|
|
|
if expectedStatus != http.StatusOK {
|
|
return ""
|
|
}
|
|
obj := DecodeJSON(t, resp, map[string]string{})
|
|
return obj["uuid"]
|
|
}
|
|
|
|
func testDeleteIssueAttachment(t *testing.T, session *TestSession, repoURL, uuid string, expectedStatus int) {
|
|
req := NewRequestWithValues(t, "POST", repoURL+"/issues/attachments/remove", map[string]string{"file": uuid})
|
|
session.MakeRequest(t, req, expectedStatus)
|
|
}
|
|
|
|
func testDeleteReleaseAttachment(t *testing.T, session *TestSession, repoURL, uuid string, expectedStatus int) {
|
|
req := NewRequestWithValues(t, "POST", repoURL+"/releases/attachments/remove", map[string]string{"file": uuid})
|
|
session.MakeRequest(t, req, expectedStatus)
|
|
}
|
|
|
|
func TestAttachments(t *testing.T) {
|
|
defer tests.PrepareTestEnv(t)()
|
|
t.Run("CreateAnonymousAttachment", testCreateAnonymousAttachment)
|
|
t.Run("CreateUser2IssueAttachment", testCreateUser2IssueAttachment)
|
|
t.Run("UploadAttachmentDeleteTemp", testUploadAttachmentDeleteTemp)
|
|
t.Run("GetAttachment", testGetAttachment)
|
|
t.Run("DeleteAttachmentPermissions", testDeleteAttachmentPermissions)
|
|
}
|
|
|
|
func testUploadAttachmentDeleteTemp(t *testing.T) {
|
|
session := loginUser(t, "user2")
|
|
countTmpFile := func() int {
|
|
// TODO: GOLANG-HTTP-TMPDIR: Golang saves the uploaded file to os.TempDir() when it exceeds the max memory limit.
|
|
files, err := fs.Glob(os.DirFS(os.TempDir()), "multipart-*") //nolint:usetesting // Golang's "http" package's behavior
|
|
require.NoError(t, err)
|
|
return len(files)
|
|
}
|
|
var tmpFileCountDuringUpload int
|
|
defer test.MockVariableValue(&context.ParseMultipartFormMaxMemory, 1)()
|
|
defer web.RouteMock(route_web.RouterMockPointBeforeWebRoutes, func(resp http.ResponseWriter, req *http.Request) {
|
|
tmpFileCountDuringUpload = countTmpFile()
|
|
})()
|
|
_ = testCreateIssueAttachment(t, session, "/user2/repo1", "image.png", testGeneratePngBytes(), http.StatusOK)
|
|
assert.Equal(t, 1, tmpFileCountDuringUpload, "the temp file should exist when uploaded size exceeds the parse form's max memory")
|
|
assert.Equal(t, 0, countTmpFile(), "the temp file should be deleted after upload")
|
|
}
|
|
|
|
func testCreateAnonymousAttachment(t *testing.T) {
|
|
session := emptyTestSession(t)
|
|
testCreateIssueAttachment(t, session, "/user2/repo1", "image.png", testGeneratePngBytes(), http.StatusSeeOther)
|
|
}
|
|
|
|
func testCreateUser2IssueAttachment(t *testing.T) {
|
|
const repoURL = "/user2/repo1"
|
|
session := loginUser(t, "user2")
|
|
uuid := testCreateIssueAttachment(t, session, repoURL, "image.png", testGeneratePngBytes(), http.StatusOK)
|
|
|
|
req := NewRequest(t, "GET", repoURL+"/issues/new")
|
|
resp := session.MakeRequest(t, req, http.StatusOK)
|
|
htmlDoc := NewHTMLParser(t, resp.Body)
|
|
|
|
link, exists := htmlDoc.doc.Find("form#new-issue").Attr("action")
|
|
assert.True(t, exists, "The template has changed")
|
|
|
|
postData := map[string]string{
|
|
"title": "New Issue With Attachment",
|
|
"content": "some content",
|
|
"files": uuid,
|
|
}
|
|
|
|
req = NewRequestWithValues(t, "POST", link, postData)
|
|
resp = session.MakeRequest(t, req, http.StatusOK)
|
|
test.RedirectURL(resp) // check that redirect URL exists
|
|
|
|
// Validate that attachment is available
|
|
req = NewRequest(t, "GET", "/attachments/"+uuid)
|
|
session.MakeRequest(t, req, http.StatusOK)
|
|
|
|
// anonymous visit should be allowed because user2/repo1 is a public repository
|
|
MakeRequest(t, req, http.StatusOK)
|
|
}
|
|
|
|
func testGetAttachment(t *testing.T) {
|
|
adminSession := loginUser(t, "user1")
|
|
user2Session := loginUser(t, "user2")
|
|
user8Session := loginUser(t, "user8")
|
|
emptySession := emptyTestSession(t)
|
|
testCases := []struct {
|
|
name string
|
|
uuid string
|
|
createFile bool
|
|
session *TestSession
|
|
want int
|
|
}{
|
|
{"LinkedIssueUUID", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", true, user2Session, http.StatusOK},
|
|
{"LinkedCommentUUID", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a17", true, user2Session, http.StatusOK},
|
|
{"linked_release_uuid", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a19", true, user2Session, http.StatusOK},
|
|
{"NotExistingUUID", "b0eebc99-9c0b-4ef8-bb6d-6bb9bd380a18", false, user2Session, http.StatusNotFound},
|
|
{"FileMissing", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a18", false, user2Session, http.StatusInternalServerError},
|
|
{"NotLinked", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a20", true, user2Session, http.StatusNotFound},
|
|
{"NotLinkedAccessibleByUploader", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a20", true, user8Session, http.StatusOK},
|
|
{"PublicByNonLogged", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", true, emptySession, http.StatusOK},
|
|
{"PrivateByNonLogged", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, emptySession, http.StatusNotFound},
|
|
{"PrivateAccessibleByAdmin", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, adminSession, http.StatusOK},
|
|
{"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},
|
|
}
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Write empty file to be available for response
|
|
if tc.createFile {
|
|
_, err := storage.Attachments.Save(repo_model.AttachmentRelativePath(tc.uuid), strings.NewReader("hello world"), -1)
|
|
assert.NoError(t, err)
|
|
}
|
|
// Actual test
|
|
req := NewRequest(t, "GET", "/attachments/"+tc.uuid)
|
|
tc.session.MakeRequest(t, req, tc.want)
|
|
})
|
|
}
|
|
}
|
|
|
|
func testDeleteAttachmentPermissions(t *testing.T) {
|
|
const repoURL = "/user2/repo1"
|
|
|
|
ownerSession := loginUser(t, "user2")
|
|
readonlySession := loginUser(t, "user5")
|
|
|
|
issueFromOwner := testCreateIssueAttachment(t, ownerSession, repoURL, "owner-issue.png", testGeneratePngBytes(), http.StatusOK)
|
|
testDeleteIssueAttachment(t, readonlySession, repoURL, issueFromOwner, http.StatusForbidden)
|
|
|
|
issueFromReader := testCreateIssueAttachment(t, readonlySession, repoURL, "reader-issue.png", testGeneratePngBytes(), http.StatusOK)
|
|
testDeleteIssueAttachment(t, ownerSession, repoURL, issueFromReader, http.StatusOK)
|
|
|
|
testCreateReleaseAttachment(t, readonlySession, repoURL, "reader-release.png", testGeneratePngBytes(), http.StatusNotFound)
|
|
|
|
crossRepoUUID := testCreateIssueAttachment(t, ownerSession, repoURL, "cross-repo.png", testGeneratePngBytes(), http.StatusOK)
|
|
testDeleteIssueAttachment(t, ownerSession, "/user2/repo2", crossRepoUUID, http.StatusBadRequest)
|
|
testDeleteIssueAttachment(t, ownerSession, repoURL, crossRepoUUID, http.StatusOK)
|
|
|
|
releaseUUID := testCreateReleaseAttachment(t, ownerSession, repoURL, "reader-release.png", testGeneratePngBytes(), http.StatusOK)
|
|
testDeleteReleaseAttachment(t, ownerSession, repoURL, releaseUUID, http.StatusOK)
|
|
|
|
// 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)
|
|
})
|
|
}
|
|
}
|