diff --git a/modules/util/error.go b/modules/util/error.go index 6b2721618ec60..24fa1ba151dd3 100644 --- a/modules/util/error.go +++ b/modules/util/error.go @@ -16,6 +16,7 @@ var ( ErrPermissionDenied = errors.New("permission denied") // also implies HTTP 403 ErrNotExist = errors.New("resource does not exist") // also implies HTTP 404 ErrAlreadyExist = errors.New("resource already exists") // also implies HTTP 409 + ErrContentTooLarge = errors.New("content exceeds limit") // also implies HTTP 413 // ErrUnprocessableContent implies HTTP 422, the syntax of the request content is correct, // but the server is unable to process the contained instructions diff --git a/routers/api/v1/repo/issue_attachment.go b/routers/api/v1/repo/issue_attachment.go index 3f751a295c37e..841b048ed59c6 100644 --- a/routers/api/v1/repo/issue_attachment.go +++ b/routers/api/v1/repo/issue_attachment.go @@ -4,6 +4,7 @@ package repo import ( + "errors" "net/http" issues_model "code.gitea.io/gitea/models/issues" @@ -11,6 +12,7 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" attachment_service "code.gitea.io/gitea/services/attachment" "code.gitea.io/gitea/services/context" @@ -154,6 +156,8 @@ func CreateIssueAttachment(ctx *context.APIContext) { // "$ref": "#/responses/error" // "404": // "$ref": "#/responses/error" + // "413": + // "$ref": "#/responses/error" // "422": // "$ref": "#/responses/validationError" // "423": @@ -181,7 +185,7 @@ func CreateIssueAttachment(ctx *context.APIContext) { filename = query } - attachment, err := attachment_service.UploadAttachment(ctx, file, setting.Attachment.AllowedTypes, header.Size, &repo_model.Attachment{ + attachment, err := attachment_service.UploadAttachment(ctx, file, setting.Attachment.AllowedTypes, setting.Attachment.MaxSize<<20, header.Size, &repo_model.Attachment{ Name: filename, UploaderID: ctx.Doer.ID, RepoID: ctx.Repo.Repository.ID, @@ -190,6 +194,8 @@ func CreateIssueAttachment(ctx *context.APIContext) { if err != nil { if upload.IsErrFileTypeForbidden(err) { ctx.APIError(http.StatusUnprocessableEntity, err) + } else if errors.Is(err, util.ErrContentTooLarge) { + ctx.APIError(http.StatusRequestEntityTooLarge, err) } else { ctx.APIErrorInternal(err) } diff --git a/routers/api/v1/repo/issue_comment_attachment.go b/routers/api/v1/repo/issue_comment_attachment.go index 5f660c57504dd..e5ec120464397 100644 --- a/routers/api/v1/repo/issue_comment_attachment.go +++ b/routers/api/v1/repo/issue_comment_attachment.go @@ -13,6 +13,7 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" attachment_service "code.gitea.io/gitea/services/attachment" "code.gitea.io/gitea/services/context" @@ -161,6 +162,8 @@ func CreateIssueCommentAttachment(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/error" + // "413": + // "$ref": "#/responses/error" // "422": // "$ref": "#/responses/validationError" // "423": @@ -189,7 +192,7 @@ func CreateIssueCommentAttachment(ctx *context.APIContext) { filename = query } - attachment, err := attachment_service.UploadAttachment(ctx, file, setting.Attachment.AllowedTypes, header.Size, &repo_model.Attachment{ + attachment, err := attachment_service.UploadAttachment(ctx, file, setting.Attachment.AllowedTypes, setting.Attachment.MaxSize<<20, header.Size, &repo_model.Attachment{ Name: filename, UploaderID: ctx.Doer.ID, RepoID: ctx.Repo.Repository.ID, @@ -199,6 +202,8 @@ func CreateIssueCommentAttachment(ctx *context.APIContext) { if err != nil { if upload.IsErrFileTypeForbidden(err) { ctx.APIError(http.StatusUnprocessableEntity, err) + } else if errors.Is(err, util.ErrContentTooLarge) { + ctx.APIError(http.StatusRequestEntityTooLarge, err) } else { ctx.APIErrorInternal(err) } diff --git a/routers/api/v1/repo/release_attachment.go b/routers/api/v1/repo/release_attachment.go index defde81a1d2ae..1e483af3e68f6 100644 --- a/routers/api/v1/repo/release_attachment.go +++ b/routers/api/v1/repo/release_attachment.go @@ -4,6 +4,7 @@ package repo import ( + "errors" "io" "net/http" "strings" @@ -12,6 +13,7 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" attachment_service "code.gitea.io/gitea/services/attachment" "code.gitea.io/gitea/services/context" @@ -191,6 +193,8 @@ func CreateReleaseAttachment(ctx *context.APIContext) { // "$ref": "#/responses/error" // "404": // "$ref": "#/responses/notFound" + // "413": + // "$ref": "#/responses/error" // Check if attachments are enabled if !setting.Attachment.Enabled { @@ -234,7 +238,7 @@ func CreateReleaseAttachment(ctx *context.APIContext) { } // Create a new attachment and save the file - attach, err := attachment_service.UploadAttachment(ctx, content, setting.Repository.Release.AllowedTypes, size, &repo_model.Attachment{ + attach, err := attachment_service.UploadAttachment(ctx, content, setting.Repository.Release.AllowedTypes, setting.Attachment.MaxSize<<20, size, &repo_model.Attachment{ Name: filename, UploaderID: ctx.Doer.ID, RepoID: ctx.Repo.Repository.ID, @@ -245,6 +249,12 @@ func CreateReleaseAttachment(ctx *context.APIContext) { ctx.APIError(http.StatusBadRequest, err) return } + + if errors.Is(err, util.ErrContentTooLarge) { + ctx.APIError(http.StatusRequestEntityTooLarge, err) + return + } + ctx.APIErrorInternal(err) return } diff --git a/routers/web/repo/attachment.go b/routers/web/repo/attachment.go index f696669196100..e6ba598c2b638 100644 --- a/routers/web/repo/attachment.go +++ b/routers/web/repo/attachment.go @@ -45,7 +45,7 @@ func uploadAttachment(ctx *context.Context, repoID int64, allowedTypes string) { } defer file.Close() - attach, err := attachment.UploadAttachment(ctx, file, allowedTypes, header.Size, &repo_model.Attachment{ + attach, err := attachment.UploadAttachment(ctx, file, allowedTypes, setting.Attachment.MaxSize<<20, header.Size, &repo_model.Attachment{ Name: header.Filename, UploaderID: ctx.Doer.ID, RepoID: repoID, diff --git a/services/attachment/attachment.go b/services/attachment/attachment.go index ccb97c66c82b1..49b10850ce891 100644 --- a/services/attachment/attachment.go +++ b/services/attachment/attachment.go @@ -24,7 +24,7 @@ func NewAttachment(ctx context.Context, attach *repo_model.Attachment, file io.R return nil, fmt.Errorf("attachment %s should belong to a repository", attach.Name) } - err := db.WithTx(ctx, func(ctx context.Context) error { + if err := db.WithTx(ctx, func(ctx context.Context) error { attach.UUID = uuid.New().String() size, err := storage.Attachments.Save(attach.RelativePath(), file, size) if err != nil { @@ -33,13 +33,36 @@ func NewAttachment(ctx context.Context, attach *repo_model.Attachment, file io.R attach.Size = size return db.Insert(ctx, attach) - }) + }); err != nil { + return nil, err + } + + return attach, nil +} - return attach, err +type ErrAttachmentSizeExceed struct { + MaxSize int64 + Size int64 +} + +func (e *ErrAttachmentSizeExceed) Error() string { + if e.Size == 0 { + return fmt.Sprintf("attachment size exceeds limit %d", e.MaxSize) + } + return fmt.Sprintf("attachment size %d exceeds limit %d", e.Size, e.MaxSize) +} + +func (e *ErrAttachmentSizeExceed) Unwrap() error { + return util.ErrContentTooLarge +} + +func (e *ErrAttachmentSizeExceed) Is(target error) bool { + _, ok := target.(*ErrAttachmentSizeExceed) + return ok } // UploadAttachment upload new attachment into storage and update database -func UploadAttachment(ctx context.Context, file io.Reader, allowedTypes string, fileSize int64, attach *repo_model.Attachment) (*repo_model.Attachment, error) { +func UploadAttachment(ctx context.Context, file io.Reader, allowedTypes string, maxFileSize, fileSize int64, attach *repo_model.Attachment) (*repo_model.Attachment, error) { buf := make([]byte, 1024) n, _ := util.ReadAtMost(file, buf) buf = buf[:n] @@ -48,7 +71,20 @@ func UploadAttachment(ctx context.Context, file io.Reader, allowedTypes string, return nil, err } - return NewAttachment(ctx, attach, io.MultiReader(bytes.NewReader(buf), file), fileSize) + reader := io.MultiReader(bytes.NewReader(buf), file) + + // enforce file size limit + if maxFileSize >= 0 { + if fileSize > maxFileSize { + return nil, &ErrAttachmentSizeExceed{MaxSize: maxFileSize, Size: fileSize} + } + // limit reader to max file size with additional 1k more, + // to allow side-cases where encoding tells us its exactly maxFileSize but the actual created file is bit more, + // while still make sure the limit is enforced + reader = attachmentLimitedReader(reader, maxFileSize+1024) + } + + return NewAttachment(ctx, attach, reader, fileSize) } // UpdateAttachment updates an attachment, verifying that its name is among the allowed types. diff --git a/services/attachment/attachment_test.go b/services/attachment/attachment_test.go index 8ecac8d7a33ec..522bf10476e63 100644 --- a/services/attachment/attachment_test.go +++ b/services/attachment/attachment_test.go @@ -15,31 +15,77 @@ import ( _ "code.gitea.io/gitea/models/actions" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestMain(m *testing.M) { unittest.MainTest(m) } -func TestUploadAttachment(t *testing.T) { +func TestNewAttachment(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) fPath := "./attachment_test.go" f, err := os.Open(fPath) - assert.NoError(t, err) + require.NoError(t, err) defer f.Close() + fs, err := f.Stat() + require.NoError(t, err) attach, err := NewAttachment(t.Context(), &repo_model.Attachment{ RepoID: 1, UploaderID: user.ID, Name: filepath.Base(fPath), - }, f, -1) + }, f, fs.Size()) assert.NoError(t, err) + assert.Equal(t, fs.Size(), attach.Size) attachment, err := repo_model.GetAttachmentByUUID(t.Context(), attach.UUID) assert.NoError(t, err) assert.Equal(t, user.ID, attachment.UploaderID) assert.Equal(t, int64(0), attachment.DownloadCount) } + +func TestUploadAttachment(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + fPath := "./attachment_test.go" + f, err := os.Open(fPath) + require.NoError(t, err) + defer f.Close() + fs, err := f.Stat() + require.NoError(t, err) + + t.Run("size to big", func(t *testing.T) { + attach, err := UploadAttachment(t.Context(), f, "", 10, fs.Size(), &repo_model.Attachment{ + RepoID: 1, + UploaderID: user.ID, + Name: filepath.Base(fPath), + }) + assert.ErrorIs(t, err, &ErrAttachmentSizeExceed{}) + assert.Nil(t, attach) + }) + + t.Run("size was lied about", func(t *testing.T) { + attach, err := UploadAttachment(t.Context(), f, "", 10, 10, &repo_model.Attachment{ + RepoID: 1, + UploaderID: user.ID, + Name: filepath.Base(fPath), + }) + assert.ErrorIs(t, err, &ErrAttachmentSizeExceed{}) + assert.Nil(t, attach) + }) + + t.Run("size was correct", func(t *testing.T) { + attach, err := UploadAttachment(t.Context(), f, "", fs.Size(), fs.Size(), &repo_model.Attachment{ + RepoID: 1, + UploaderID: user.ID, + Name: filepath.Base(fPath), + }) + assert.NoError(t, err) + require.NotNil(t, attach) + assert.Equal(t, user.ID, attach.UploaderID) + }) +} diff --git a/services/attachment/reader.go b/services/attachment/reader.go new file mode 100644 index 0000000000000..db0e44301eec0 --- /dev/null +++ b/services/attachment/reader.go @@ -0,0 +1,35 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package attachment + +import "io" + +// modified version of io.LimitReader: https://cs.opensource.google/go/go/+/refs/tags/go1.25.1:src/io/io.go;l=458-482 + +// attachmentLimitedReader returns a Reader that reads from r +// but errors with ErrAttachmentSizeExceed after n bytes. +// The underlying implementation is a *attachmentReader. +func attachmentLimitedReader(r io.Reader, n int64) io.Reader { return &attachmentReader{r, n} } + +// A attachmentReader reads from R but limits the amount of +// data returned to just N bytes. Each call to Read +// updates N to reflect the new amount remaining. +// Read returns ErrAttachmentSizeExceed when N <= 0. +// Underlying errors are passed through. +type attachmentReader struct { + R io.Reader // underlying reader + N int64 // max bytes remaining +} + +func (l *attachmentReader) Read(p []byte) (n int, err error) { + if l.N <= 0 { + return 0, &ErrAttachmentSizeExceed{MaxSize: l.N} + } + if int64(len(p)) > l.N { + p = p[0:l.N] + } + n, err = l.R.Read(p) + l.N -= int64(n) + return +} diff --git a/services/mailer/incoming/incoming_handler.go b/services/mailer/incoming/incoming_handler.go index 38a234eac1fd2..77ebc8d0cc42b 100644 --- a/services/mailer/incoming/incoming_handler.go +++ b/services/mailer/incoming/incoming_handler.go @@ -6,6 +6,7 @@ package incoming import ( "bytes" "context" + "errors" "fmt" issues_model "code.gitea.io/gitea/models/issues" @@ -85,7 +86,7 @@ func (h *ReplyHandler) Handle(ctx context.Context, content *MailContent, doer *u attachmentIDs := make([]string, 0, len(content.Attachments)) if setting.Attachment.Enabled { for _, attachment := range content.Attachments { - a, err := attachment_service.UploadAttachment(ctx, bytes.NewReader(attachment.Content), setting.Attachment.AllowedTypes, int64(len(attachment.Content)), &repo_model.Attachment{ + a, err := attachment_service.UploadAttachment(ctx, bytes.NewReader(attachment.Content), setting.Attachment.AllowedTypes, setting.Attachment.MaxSize<<20, int64(len(attachment.Content)), &repo_model.Attachment{ Name: attachment.Name, UploaderID: doer.ID, RepoID: issue.Repo.ID, @@ -95,6 +96,11 @@ func (h *ReplyHandler) Handle(ctx context.Context, content *MailContent, doer *u log.Info("Skipping disallowed attachment type: %s", attachment.Name) continue } + if errors.Is(err, util.ErrContentTooLarge) { + log.Info("Skipping attachment exceeding size limit: %s", attachment.Name) + continue + } + return err } attachmentIDs = append(attachmentIDs, a.UUID) diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 0df8356fd9c38..aa8670daf0a4e 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -9569,6 +9569,9 @@ "404": { "$ref": "#/responses/error" }, + "413": { + "$ref": "#/responses/error" + }, "422": { "$ref": "#/responses/validationError" }, @@ -10194,6 +10197,9 @@ "404": { "$ref": "#/responses/error" }, + "413": { + "$ref": "#/responses/error" + }, "422": { "$ref": "#/responses/validationError" }, @@ -15510,6 +15516,9 @@ }, "404": { "$ref": "#/responses/notFound" + }, + "413": { + "$ref": "#/responses/error" } } }