diff --git a/routers/web/repo/attachment.go b/routers/web/repo/attachment.go index bb2002521c..247f6d530b 100644 --- a/routers/web/repo/attachment.go +++ b/routers/web/repo/attachment.go @@ -6,6 +6,7 @@ package repo import ( "net/http" + auth_model "code.gitea.io/gitea/models/auth" issues_model "code.gitea.io/gitea/models/issues" access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" @@ -21,6 +22,17 @@ import ( repo_service "code.gitea.io/gitea/services/repository" ) +func attachmentReadScope(unitType unit.Type) (auth_model.AccessTokenScope, bool) { + switch unitType { + case unit.TypeIssues, unit.TypePullRequests: + return auth_model.AccessTokenScopeReadIssue, true + case unit.TypeReleases: + return auth_model.AccessTokenScopeReadRepository, true + default: + return "", false + } +} + // UploadIssueAttachment response for Issue/PR attachments func UploadIssueAttachment(ctx *context.Context) { uploadAttachment(ctx, ctx.Repo.Repository.ID, attachment.UploadAttachmentForIssue) @@ -150,9 +162,12 @@ func ServeAttachment(ctx *context.Context, uuid string) { return } } else { // If we have the linked type, we need to check access - var perm access_model.Permission - if ctx.Repo.Repository == nil { - repo, err := repo_model.GetRepositoryByID(ctx, repoID) + var ( + perm access_model.Permission + repo = ctx.Repo.Repository + ) + if repo == nil { + repo, err = repo_model.GetRepositoryByID(ctx, repoID) if err != nil { ctx.ServerError("GetRepositoryByID", err) return @@ -170,6 +185,13 @@ func ServeAttachment(ctx *context.Context, uuid string) { ctx.HTTPError(http.StatusNotFound) return } + + if requiredScope, ok := attachmentReadScope(unitType); ok { + context.CheckTokenScopes(ctx, repo, requiredScope) + if ctx.Written() { + return + } + } } if err := attach.IncreaseDownloadCount(ctx); err != nil { diff --git a/routers/web/repo/download.go b/routers/web/repo/download.go index 25166ea1d3..7c74987d0d 100644 --- a/routers/web/repo/download.go +++ b/routers/web/repo/download.go @@ -7,6 +7,7 @@ package repo import ( "time" + auth_model "code.gitea.io/gitea/models/auth" git_model "code.gitea.io/gitea/models/git" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/httpcache" @@ -18,6 +19,11 @@ import ( "code.gitea.io/gitea/services/context" ) +func checkDownloadTokenScope(ctx *context.Context) bool { + context.CheckRepoScopedToken(ctx, ctx.Repo.Repository, auth_model.Read) + return !ctx.Written() +} + // ServeBlobOrLFS download a git.Blob redirecting to LFS if necessary func ServeBlobOrLFS(ctx *context.Context, blob *git.Blob, lastModified *time.Time) error { if httpcache.HandleGenericETagPrivateCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`, lastModified) { @@ -88,6 +94,10 @@ func getBlobForEntry(ctx *context.Context) (*git.Blob, *time.Time) { // SingleDownload download a file by repos path func SingleDownload(ctx *context.Context) { + if !checkDownloadTokenScope(ctx) { + return + } + blob, lastModified := getBlobForEntry(ctx) if blob == nil { return @@ -100,6 +110,10 @@ func SingleDownload(ctx *context.Context) { // SingleDownloadOrLFS download a file by repos path redirecting to LFS if necessary func SingleDownloadOrLFS(ctx *context.Context) { + if !checkDownloadTokenScope(ctx) { + return + } + blob, lastModified := getBlobForEntry(ctx) if blob == nil { return @@ -112,6 +126,10 @@ func SingleDownloadOrLFS(ctx *context.Context) { // DownloadByID download a file by sha1 ID func DownloadByID(ctx *context.Context) { + if !checkDownloadTokenScope(ctx) { + return + } + blob, err := ctx.Repo.GitRepo.GetBlob(ctx.PathParam("sha")) if err != nil { if git.IsErrNotExist(err) { @@ -128,6 +146,10 @@ func DownloadByID(ctx *context.Context) { // DownloadByIDOrLFS download a file by sha1 ID taking account of LFS func DownloadByIDOrLFS(ctx *context.Context) { + if !checkDownloadTokenScope(ctx) { + return + } + blob, err := ctx.Repo.GitRepo.GetBlob(ctx.PathParam("sha")) if err != nil { if git.IsErrNotExist(err) { diff --git a/services/context/permission.go b/services/context/permission.go index 16de86bbc6..996f24a1b4 100644 --- a/services/context/permission.go +++ b/services/context/permission.go @@ -12,6 +12,39 @@ import ( "code.gitea.io/gitea/models/unit" ) +// CheckTokenScopes checks whether the authenticated API token contains any of the given scopes. +func CheckTokenScopes(ctx *Context, repo *repo_model.Repository, scopes ...auth_model.AccessTokenScope) { + if ctx.Data["IsApiToken"] != true { + return + } + + scope, ok := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope) + if !ok { + return + } + + publicOnly, err := scope.PublicOnly() + if err != nil { + ctx.ServerError("PublicOnly", err) + return + } + + if publicOnly && repo != nil && repo.IsPrivate { + ctx.HTTPError(http.StatusForbidden) + return + } + + scopeMatched, err := scope.HasAnyScope(scopes...) + if err != nil { + ctx.ServerError("HasAnyScope", err) + return + } + + if !scopeMatched { + ctx.HTTPError(http.StatusForbidden) + } +} + // RequireRepoAdmin returns a middleware for requiring repository admin permission func RequireRepoAdmin() func(ctx *Context) { return func(ctx *Context) { @@ -59,37 +92,5 @@ func RequireUnitReader(unitTypes ...unit.Type) func(ctx *Context) { // CheckRepoScopedToken checks whether the authenticated API token has repo scope. func CheckRepoScopedToken(ctx *Context, repo *repo_model.Repository, level auth_model.AccessTokenScopeLevel) { - if ctx.Data["IsApiToken"] != true { - return - } - - scope, ok := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope) - if ok { - var scopeMatched bool - - requiredScopes := auth_model.GetRequiredScopes(level, auth_model.AccessTokenScopeCategoryRepository) - - // check if scope only applies to public resources - publicOnly, err := scope.PublicOnly() - if err != nil { - ctx.ServerError("HasScope", err) - return - } - - if publicOnly && repo != nil && repo.IsPrivate { - ctx.HTTPError(http.StatusForbidden) - return - } - - scopeMatched, err = scope.HasScope(requiredScopes...) - if err != nil { - ctx.ServerError("HasScope", err) - return - } - - if !scopeMatched { - ctx.HTTPError(http.StatusForbidden) - return - } - } + CheckTokenScopes(ctx, repo, auth_model.GetRequiredScopes(level, auth_model.AccessTokenScopeCategoryRepository)...) } diff --git a/tests/integration/attachment_test.go b/tests/integration/attachment_test.go index a00ba98b96..7fee5d3b38 100644 --- a/tests/integration/attachment_test.go +++ b/tests/integration/attachment_test.go @@ -14,6 +14,7 @@ import ( "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" @@ -26,6 +27,15 @@ import ( "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 @@ -200,3 +210,100 @@ func testDeleteAttachmentPermissions(t *testing.T) { // 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) + }) + } +} diff --git a/tests/integration/download_test.go b/tests/integration/download_test.go index 3e7be98b09..b55a5a89bb 100644 --- a/tests/integration/download_test.go +++ b/tests/integration/download_test.go @@ -7,6 +7,7 @@ import ( "net/http" "testing" + auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/tests" @@ -14,6 +15,13 @@ import ( "github.com/stretchr/testify/assert" ) +type downloadScopeCase struct { + name string + url string + withScope int + publicOnlyOK bool +} + func TestDownloadRepoContent(t *testing.T) { defer tests.PrepareTestEnv(t)() @@ -71,3 +79,93 @@ func TestDownloadRepoContent(t *testing.T) { assert.Equal(t, "application/xml", resp.Header().Get("Content-Type")) }) } + +func TestDownloadRepoContentTokenScopes(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + ownerReadToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository) + miscToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadMisc) + publicOnlyToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository, auth_model.AccessTokenScopePublicOnly) + + cases := []downloadScopeCase{ + { + name: "PublicRawBlob", + url: "/user2/repo1/raw/blob/4b4851ad51df6a7d9f25c979345979eaeb5b349f", + withScope: http.StatusOK, + publicOnlyOK: true, + }, + { + name: "PublicRawBranch", + url: "/user2/repo1/raw/branch/master/README.md", + withScope: http.StatusOK, + publicOnlyOK: true, + }, + { + name: "PublicRawTag", + url: "/user2/repo1/raw/tag/v1.1/README.md", + withScope: http.StatusOK, + publicOnlyOK: true, + }, + { + name: "PublicRawCommit", + url: "/user2/repo1/raw/commit/65f1bf27bc3bf70f64657658635e66094edbcb4d/README.md", + withScope: http.StatusOK, + publicOnlyOK: true, + }, + { + name: "PublicMediaBlob", + url: "/user2/repo1/media/blob/4b4851ad51df6a7d9f25c979345979eaeb5b349f", + withScope: http.StatusOK, + publicOnlyOK: true, + }, + { + name: "PublicMediaBranch", + url: "/user2/repo1/media/branch/master/README.md", + withScope: http.StatusOK, + publicOnlyOK: true, + }, + { + name: "PublicMediaTag", + url: "/user2/repo1/media/tag/v1.1/README.md", + withScope: http.StatusOK, + publicOnlyOK: true, + }, + { + name: "PublicMediaCommit", + url: "/user2/repo1/media/commit/65f1bf27bc3bf70f64657658635e66094edbcb4d/README.md", + withScope: http.StatusOK, + publicOnlyOK: true, + }, + { + name: "PrivateRawBranch", + url: "/user2/repo2/raw/branch/master/test.xml", + withScope: http.StatusOK, + publicOnlyOK: false, + }, + { + name: "PrivateRawBlob", + url: "/user2/repo2/raw/blob/6395b68e1feebb1e4c657b4f9f6ba2676a283c0b", + withScope: http.StatusOK, + publicOnlyOK: false, + }, + { + name: "PrivateMediaBranch", + url: "/user2/repo2/media/branch/master/test.xml", + withScope: http.StatusOK, + publicOnlyOK: false, + }, + } + + 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(ownerReadToken), tc.withScope) + + publicOnlyStatus := http.StatusForbidden + if tc.publicOnlyOK { + publicOnlyStatus = tc.withScope + } + MakeRequest(t, NewRequest(t, "GET", tc.url).AddTokenAuth(publicOnlyToken), publicOnlyStatus) + }) + } +}