0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-05-17 03:43:23 +02:00
gitea/tests/integration/attachment_test.go
Lunny Xiao 33923a4d7c
fix(web): enforce token scopes on raw, media, and attachment downloads (#37698)
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>
2026-05-16 14:50:41 +00:00

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)
})
}
}