From cda44750cbdc7a8460666a4f0ac7f652d84a3964 Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 5 Oct 2020 07:49:33 +0200 Subject: [PATCH] Attachments: Add extension support, allow all types for releases (#12465) * Attachments: Add extension support, allow all types for releases - Add support for file extensions, matching the `accept` attribute of `` - Add support for type wildcard mime types, e.g. `image/*` - Create repository.release.ALLOWED_TYPES setting (default unrestricted) - Change default for attachment.ALLOWED_TYPES to a list of extensions - Split out POST /attachments into two endpoints for issue/pr and releases to prevent circumvention of allowed types check Fixes: https://github.com/go-gitea/gitea/pull/10172 Fixes: https://github.com/go-gitea/gitea/issues/7266 Fixes: https://github.com/go-gitea/gitea/pull/12460 Ref: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#Unique_file_type_specifiers * rename function * extract GET routes out of RepoMustNotBeArchived Co-authored-by: Lauris BH --- custom/conf/app.example.ini | 13 +- .../doc/advanced/config-cheat-sheet.en-us.md | 19 +- integrations/attachment_test.go | 2 +- models/twofactor.go | 51 +---- modules/secret/secret.go | 68 ++++++ modules/secret/secret_test.go | 13 ++ modules/setting/attachment.go | 3 +- modules/setting/repository.go | 16 +- modules/upload/filetype.go | 46 ----- modules/upload/filetype_test.go | 47 ----- modules/upload/upload.go | 94 +++++++++ modules/upload/upload_test.go | 195 ++++++++++++++++++ routers/api/v1/repo/release_attachment.go | 3 +- routers/repo/attachment.go | 21 +- routers/repo/compare.go | 4 +- routers/repo/editor.go | 26 +-- routers/repo/issue.go | 12 +- routers/repo/pull.go | 4 +- routers/repo/release.go | 7 +- routers/routes/routes.go | 19 +- templates/repo/editor/upload.tmpl | 2 +- templates/repo/issue/comment_tab.tmpl | 8 +- templates/repo/issue/view_content.tmpl | 15 +- templates/repo/release/new.tmpl | 8 +- templates/repo/upload.tmpl | 13 ++ web_src/js/index.js | 14 +- 26 files changed, 497 insertions(+), 226 deletions(-) delete mode 100644 modules/upload/filetype.go delete mode 100644 modules/upload/filetype_test.go create mode 100644 modules/upload/upload.go create mode 100644 modules/upload/upload_test.go create mode 100644 templates/repo/upload.tmpl diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 1fc2c9ef0ff..44c448a4e31 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -88,7 +88,7 @@ LOCAL_COPY_PATH = tmp/local-repo ENABLED = true ; Path for uploads. Defaults to `data/tmp/uploads` (tmp gets deleted on gitea restart) TEMP_PATH = data/tmp/uploads -; One or more allowed types, e.g. image/jpeg|image/png. Nothing means any file type +; Comma-separated list of allowed file extensions (`.zip`), mime types (`text/plain`) or wildcard type (`image/*`, `audio/*`, `video/*`). Empty value or `*/*` allows all types. ALLOWED_TYPES = ; Max size of each file in megabytes. Defaults to 3MB FILE_MAX_SIZE = 3 @@ -117,6 +117,10 @@ DEFAULT_MERGE_MESSAGE_OFFICIAL_APPROVERS_ONLY=true ; List of reasons why a Pull Request or Issue can be locked LOCK_REASONS=Too heated,Off-topic,Resolved,Spam +[repository.release] +; Comma-separated list of allowed file extensions (`.zip`), mime types (`text/plain`) or wildcard type (`image/*`, `audio/*`, `video/*`). Empty value or `*/*` allows all types. +ALLOWED_TYPES = + [repository.signing] ; GPG key to use to sign commits, Defaults to the default - that is the value of git config --get user.signingkey ; run in the context of the RUN_USER @@ -766,11 +770,10 @@ DISABLE_GRAVATAR = false ENABLE_FEDERATED_AVATAR = false [attachment] -; Whether attachments are enabled. Defaults to `true` +; Whether issue and pull request attachments are enabled. Defaults to `true` ENABLED = true - -; One or more allowed types, e.g. "image/jpeg|image/png". Use "*/*" for all types. -ALLOWED_TYPES = image/jpeg|image/png|application/zip|application/gzip +; Comma-separated list of allowed file extensions (`.zip`), mime types (`text/plain`) or wildcard type (`image/*`, `audio/*`, `video/*`). Empty value or `*/*` allows all types. +ALLOWED_TYPES = .docx,.gif,.gz,.jpeg,.jpg,.log,.pdf,.png,.pptx,.txt,.xlsx,.zip ; Max size of each file. Defaults to 4MB MAX_SIZE = 4 ; Max number of files per upload. Defaults to 5 diff --git a/docs/content/doc/advanced/config-cheat-sheet.en-us.md b/docs/content/doc/advanced/config-cheat-sheet.en-us.md index fbf7affeaf1..dc3979a64d3 100644 --- a/docs/content/doc/advanced/config-cheat-sheet.en-us.md +++ b/docs/content/doc/advanced/config-cheat-sheet.en-us.md @@ -101,6 +101,18 @@ Values containing `#` or `;` must be quoted using `` ` `` or `"""`. - `LOCK_REASONS`: **Too heated,Off-topic,Resolved,Spam**: A list of reasons why a Pull Request or Issue can be locked +### Repository - Upload (`repository.upload`) + +- `ENABLED`: **true**: Whether repository file uploads are enabled +- `TEMP_PATH`: **data/tmp/uploads**: Path for uploads (tmp gets deleted on gitea restart) +- `ALLOWED_TYPES`: **\**: Comma-separated list of allowed file extensions (`.zip`), mime types (`text/plain`) or wildcard type (`image/*`, `audio/*`, `video/*`). Empty value or `*/*` allows all types. +- `FILE_MAX_SIZE`: **3**: Max size of each file in megabytes. +- `MAX_FILES`: **5**: Max number of files per upload + +### Repository - Release (`repository.release`) + +- `ALLOWED_TYPES`: **\**: Comma-separated list of allowed file extensions (`.zip`), mime types (`text/plain`) or wildcard type (`image/*`, `audio/*`, `video/*`). Empty value or `*/*` allows all types. + ### Repository - Signing (`repository.signing`) - `SIGNING_KEY`: **default**: \[none, KEYID, default \]: Key to sign with. @@ -560,11 +572,10 @@ Default templates for project boards: - `PROJECT_BOARD_BASIC_KANBAN_TYPE`: **To Do, In Progress, Done** - `PROJECT_BOARD_BUG_TRIAGE_TYPE`: **Needs Triage, High Priority, Low Priority, Closed** -## Attachment (`attachment`) +## Issue and pull request attachments (`attachment`) -- `ENABLED`: **true**: Enable this to allow uploading attachments. -- `ALLOWED_TYPES`: **see app.example.ini**: Allowed MIME types, e.g. `image/jpeg|image/png`. - Use `*/*` for all types. +- `ENABLED`: **true**: Whether issue and pull request attachments are enabled. +- `ALLOWED_TYPES`: **.docx,.gif,.gz,.jpeg,.jpg,.log,.pdf,.png,.pptx,.txt,.xlsx,.zip**: Comma-separated list of allowed file extensions (`.zip`), mime types (`text/plain`) or wildcard type (`image/*`, `audio/*`, `video/*`). Empty value or `*/*` allows all types. - `MAX_SIZE`: **4**: Maximum size (MB). - `MAX_FILES`: **5**: Maximum number of attachments that can be uploaded at once. - `STORAGE_TYPE`: **local**: Storage type for attachments, `local` for local disk or `minio` for s3 compatible object storage service, default is `local` or other name defined with `[storage.xxx]` diff --git a/integrations/attachment_test.go b/integrations/attachment_test.go index 7219adf7d75..dd734145d2b 100644 --- a/integrations/attachment_test.go +++ b/integrations/attachment_test.go @@ -43,7 +43,7 @@ func createAttachment(t *testing.T, session *TestSession, repoURL, filename stri csrf := GetCSRF(t, session, repoURL) - req := NewRequestWithBody(t, "POST", "/attachments", body) + req := NewRequestWithBody(t, "POST", repoURL+"/issues/attachments", body) req.Header.Add("X-Csrf-Token", csrf) req.Header.Add("Content-Type", writer.FormDataContentType()) resp := session.MakeRequest(t, req, expectedStatus) diff --git a/models/twofactor.go b/models/twofactor.go index 888c910b942..a84da8cdb52 100644 --- a/models/twofactor.go +++ b/models/twofactor.go @@ -5,18 +5,14 @@ package models import ( - "crypto/aes" - "crypto/cipher" "crypto/md5" - "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" - "errors" "fmt" - "io" "code.gitea.io/gitea/modules/generate" + "code.gitea.io/gitea/modules/secret" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" @@ -67,8 +63,8 @@ func (t *TwoFactor) getEncryptionKey() []byte { } // SetSecret sets the 2FA secret. -func (t *TwoFactor) SetSecret(secret string) error { - secretBytes, err := aesEncrypt(t.getEncryptionKey(), []byte(secret)) +func (t *TwoFactor) SetSecret(secretString string) error { + secretBytes, err := secret.AesEncrypt(t.getEncryptionKey(), []byte(secretString)) if err != nil { return err } @@ -82,51 +78,14 @@ func (t *TwoFactor) ValidateTOTP(passcode string) (bool, error) { if err != nil { return false, err } - secret, err := aesDecrypt(t.getEncryptionKey(), decodedStoredSecret) + secretBytes, err := secret.AesDecrypt(t.getEncryptionKey(), decodedStoredSecret) if err != nil { return false, err } - secretStr := string(secret) + secretStr := string(secretBytes) return totp.Validate(passcode, secretStr), nil } -// aesEncrypt encrypts text and given key with AES. -func aesEncrypt(key, text []byte) ([]byte, error) { - block, err := aes.NewCipher(key) - if err != nil { - return nil, err - } - b := base64.StdEncoding.EncodeToString(text) - ciphertext := make([]byte, aes.BlockSize+len(b)) - iv := ciphertext[:aes.BlockSize] - if _, err := io.ReadFull(rand.Reader, iv); err != nil { - return nil, err - } - cfb := cipher.NewCFBEncrypter(block, iv) - cfb.XORKeyStream(ciphertext[aes.BlockSize:], []byte(b)) - return ciphertext, nil -} - -// aesDecrypt decrypts text and given key with AES. -func aesDecrypt(key, text []byte) ([]byte, error) { - block, err := aes.NewCipher(key) - if err != nil { - return nil, err - } - if len(text) < aes.BlockSize { - return nil, errors.New("ciphertext too short") - } - iv := text[:aes.BlockSize] - text = text[aes.BlockSize:] - cfb := cipher.NewCFBDecrypter(block, iv) - cfb.XORKeyStream(text, text) - data, err := base64.StdEncoding.DecodeString(string(text)) - if err != nil { - return nil, err - } - return data, nil -} - // NewTwoFactor creates a new two-factor authentication token. func NewTwoFactor(t *TwoFactor) error { _, err := x.Insert(t) diff --git a/modules/secret/secret.go b/modules/secret/secret.go index d0e4deacb90..2b6e22cc6c7 100644 --- a/modules/secret/secret.go +++ b/modules/secret/secret.go @@ -5,8 +5,14 @@ package secret import ( + "crypto/aes" + "crypto/cipher" "crypto/rand" + "crypto/sha256" "encoding/base64" + "encoding/hex" + "errors" + "io" ) // New creats a new secret @@ -31,3 +37,65 @@ func randomString(len int64) (string, error) { b, err := randomBytes(len) return base64.URLEncoding.EncodeToString(b), err } + +// AesEncrypt encrypts text and given key with AES. +func AesEncrypt(key, text []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + b := base64.StdEncoding.EncodeToString(text) + ciphertext := make([]byte, aes.BlockSize+len(b)) + iv := ciphertext[:aes.BlockSize] + if _, err := io.ReadFull(rand.Reader, iv); err != nil { + return nil, err + } + cfb := cipher.NewCFBEncrypter(block, iv) + cfb.XORKeyStream(ciphertext[aes.BlockSize:], []byte(b)) + return ciphertext, nil +} + +// AesDecrypt decrypts text and given key with AES. +func AesDecrypt(key, text []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + if len(text) < aes.BlockSize { + return nil, errors.New("ciphertext too short") + } + iv := text[:aes.BlockSize] + text = text[aes.BlockSize:] + cfb := cipher.NewCFBDecrypter(block, iv) + cfb.XORKeyStream(text, text) + data, err := base64.StdEncoding.DecodeString(string(text)) + if err != nil { + return nil, err + } + return data, nil +} + +// EncryptSecret encrypts a string with given key into a hex string +func EncryptSecret(key string, str string) (string, error) { + keyHash := sha256.Sum256([]byte(key)) + plaintext := []byte(str) + ciphertext, err := AesEncrypt(keyHash[:], plaintext) + if err != nil { + return "", err + } + return hex.EncodeToString(ciphertext), nil +} + +// DecryptSecret decrypts a previously encrypted hex string +func DecryptSecret(key string, cipherhex string) (string, error) { + keyHash := sha256.Sum256([]byte(key)) + ciphertext, err := hex.DecodeString(cipherhex) + if err != nil { + return "", err + } + plaintext, err := AesDecrypt(keyHash[:], ciphertext) + if err != nil { + return "", err + } + return string(plaintext), nil +} diff --git a/modules/secret/secret_test.go b/modules/secret/secret_test.go index c47201f2d74..6531ffbebc7 100644 --- a/modules/secret/secret_test.go +++ b/modules/secret/secret_test.go @@ -20,3 +20,16 @@ func TestNew(t *testing.T) { // check if secrets assert.NotEqual(t, result, result2) } + +func TestEncryptDecrypt(t *testing.T) { + var hex string + var str string + + hex, _ = EncryptSecret("foo", "baz") + str, _ = DecryptSecret("foo", hex) + assert.Equal(t, str, "baz") + + hex, _ = EncryptSecret("bar", "baz") + str, _ = DecryptSecret("foo", hex) + assert.NotEqual(t, str, "baz") +} diff --git a/modules/setting/attachment.go b/modules/setting/attachment.go index 56ccf5bc57b..a51b23913a2 100644 --- a/modules/setting/attachment.go +++ b/modules/setting/attachment.go @@ -6,7 +6,6 @@ package setting import ( "path/filepath" - "strings" "code.gitea.io/gitea/modules/log" ) @@ -65,7 +64,7 @@ func newAttachmentService() { Attachment.Minio.BasePath = sec.Key("MINIO_BASE_PATH").MustString("attachments/") } - Attachment.AllowedTypes = strings.Replace(sec.Key("ALLOWED_TYPES").MustString("image/jpeg,image/png,application/zip,application/gzip"), "|", ",", -1) + Attachment.AllowedTypes = sec.Key("ALLOWED_TYPES").MustString(".docx,.gif,.gz,.jpeg,.jpg,.log,.pdf,.png,.pptx,.txt,.xlsx,.zip") Attachment.MaxSize = sec.Key("MAX_SIZE").MustInt64(4) Attachment.MaxFiles = sec.Key("MAX_FILES").MustInt(5) Attachment.Enabled = sec.Key("ENABLED").MustBool(true) diff --git a/modules/setting/repository.go b/modules/setting/repository.go index 5203a1bbeba..96159e2f4ad 100644 --- a/modules/setting/repository.go +++ b/modules/setting/repository.go @@ -58,7 +58,7 @@ var ( Upload struct { Enabled bool TempPath string - AllowedTypes []string `delim:"|"` + AllowedTypes string FileMaxSize int64 MaxFiles int } `ini:"-"` @@ -85,6 +85,10 @@ var ( LockReasons []string } `ini:"repository.issue"` + Release struct { + AllowedTypes string + } `ini:"repository.release"` + Signing struct { SigningKey string SigningName string @@ -165,13 +169,13 @@ var ( Upload: struct { Enabled bool TempPath string - AllowedTypes []string `delim:"|"` + AllowedTypes string FileMaxSize int64 MaxFiles int }{ Enabled: true, TempPath: "data/tmp/uploads", - AllowedTypes: []string{}, + AllowedTypes: "", FileMaxSize: 3, MaxFiles: 5, }, @@ -213,6 +217,12 @@ var ( LockReasons: strings.Split("Too heated,Off-topic,Spam,Resolved", ","), }, + Release: struct { + AllowedTypes string + }{ + AllowedTypes: "", + }, + // Signing settings Signing: struct { SigningKey string diff --git a/modules/upload/filetype.go b/modules/upload/filetype.go deleted file mode 100644 index 2ab326d1169..00000000000 --- a/modules/upload/filetype.go +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright 2019 The Gitea Authors. All rights reserved. -// Use of this source code is governed by a MIT-style -// license that can be found in the LICENSE file. - -package upload - -import ( - "fmt" - "net/http" - "strings" - - "code.gitea.io/gitea/modules/log" -) - -// ErrFileTypeForbidden not allowed file type error -type ErrFileTypeForbidden struct { - Type string -} - -// IsErrFileTypeForbidden checks if an error is a ErrFileTypeForbidden. -func IsErrFileTypeForbidden(err error) bool { - _, ok := err.(ErrFileTypeForbidden) - return ok -} - -func (err ErrFileTypeForbidden) Error() string { - return fmt.Sprintf("File type is not allowed: %s", err.Type) -} - -// VerifyAllowedContentType validates a file is allowed to be uploaded. -func VerifyAllowedContentType(buf []byte, allowedTypes []string) error { - fileType := http.DetectContentType(buf) - - for _, t := range allowedTypes { - t := strings.Trim(t, " ") - - if t == "*/*" || t == fileType || - // Allow directives after type, like 'text/plain; charset=utf-8' - strings.HasPrefix(fileType, t+";") { - return nil - } - } - - log.Info("Attachment with type %s blocked from upload", fileType) - return ErrFileTypeForbidden{Type: fileType} -} diff --git a/modules/upload/filetype_test.go b/modules/upload/filetype_test.go deleted file mode 100644 index f93a1c5cc3b..00000000000 --- a/modules/upload/filetype_test.go +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright 2019 The Gitea Authors. All rights reserved. -// Use of this source code is governed by a MIT-style -// license that can be found in the LICENSE file. - -package upload - -import ( - "bytes" - "compress/gzip" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestUpload(t *testing.T) { - testContent := []byte(`This is a plain text file.`) - var b bytes.Buffer - w := gzip.NewWriter(&b) - w.Write(testContent) - w.Close() - - kases := []struct { - data []byte - allowedTypes []string - err error - }{ - { - data: testContent, - allowedTypes: []string{"text/plain"}, - err: nil, - }, - { - data: testContent, - allowedTypes: []string{"application/x-gzip"}, - err: ErrFileTypeForbidden{"text/plain; charset=utf-8"}, - }, - { - data: b.Bytes(), - allowedTypes: []string{"application/x-gzip"}, - err: nil, - }, - } - - for _, kase := range kases { - assert.Equal(t, kase.err, VerifyAllowedContentType(kase.data, kase.allowedTypes)) - } -} diff --git a/modules/upload/upload.go b/modules/upload/upload.go new file mode 100644 index 00000000000..e020faca7ea --- /dev/null +++ b/modules/upload/upload.go @@ -0,0 +1,94 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package upload + +import ( + "net/http" + "path" + "regexp" + "strings" + + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" +) + +// ErrFileTypeForbidden not allowed file type error +type ErrFileTypeForbidden struct { + Type string +} + +// IsErrFileTypeForbidden checks if an error is a ErrFileTypeForbidden. +func IsErrFileTypeForbidden(err error) bool { + _, ok := err.(ErrFileTypeForbidden) + return ok +} + +func (err ErrFileTypeForbidden) Error() string { + return "This file extension or type is not allowed to be uploaded." +} + +var mimeTypeSuffixRe = regexp.MustCompile(`;.*$`) +var wildcardTypeRe = regexp.MustCompile(`^[a-z]+/\*$`) + +// Verify validates whether a file is allowed to be uploaded. +func Verify(buf []byte, fileName string, allowedTypesStr string) error { + allowedTypesStr = strings.ReplaceAll(allowedTypesStr, "|", ",") // compat for old config format + + allowedTypes := []string{} + for _, entry := range strings.Split(allowedTypesStr, ",") { + entry = strings.ToLower(strings.TrimSpace(entry)) + if entry != "" { + allowedTypes = append(allowedTypes, entry) + } + } + + if len(allowedTypes) == 0 { + return nil // everything is allowed + } + + fullMimeType := http.DetectContentType(buf) + mimeType := strings.TrimSpace(mimeTypeSuffixRe.ReplaceAllString(fullMimeType, "")) + extension := strings.ToLower(path.Ext(fileName)) + + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#Unique_file_type_specifiers + for _, allowEntry := range allowedTypes { + if allowEntry == "*/*" { + return nil // everything allowed + } else if strings.HasPrefix(allowEntry, ".") && allowEntry == extension { + return nil // extension is allowed + } else if mimeType == allowEntry { + return nil // mime type is allowed + } else if wildcardTypeRe.MatchString(allowEntry) && strings.HasPrefix(mimeType, allowEntry[:len(allowEntry)-1]) { + return nil // wildcard match, e.g. image/* + } + } + + log.Info("Attachment with type %s blocked from upload", fullMimeType) + return ErrFileTypeForbidden{Type: fullMimeType} +} + +// AddUploadContext renders template values for dropzone +func AddUploadContext(ctx *context.Context, uploadType string) { + if uploadType == "release" { + ctx.Data["UploadUrl"] = ctx.Repo.RepoLink + "/releases/attachments" + ctx.Data["UploadRemoveUrl"] = ctx.Repo.RepoLink + "/releases/attachments/remove" + ctx.Data["UploadAccepts"] = strings.Replace(setting.Repository.Release.AllowedTypes, "|", ",", -1) + ctx.Data["UploadMaxFiles"] = setting.Attachment.MaxFiles + ctx.Data["UploadMaxSize"] = setting.Attachment.MaxSize + } else if uploadType == "comment" { + ctx.Data["UploadUrl"] = ctx.Repo.RepoLink + "/issues/attachments" + ctx.Data["UploadRemoveUrl"] = ctx.Repo.RepoLink + "/issues/attachments/remove" + ctx.Data["UploadAccepts"] = strings.Replace(setting.Attachment.AllowedTypes, "|", ",", -1) + ctx.Data["UploadMaxFiles"] = setting.Attachment.MaxFiles + ctx.Data["UploadMaxSize"] = setting.Attachment.MaxSize + } else if uploadType == "repo" { + ctx.Data["UploadUrl"] = ctx.Repo.RepoLink + "/upload-file" + ctx.Data["UploadRemoveUrl"] = ctx.Repo.RepoLink + "/upload-remove" + ctx.Data["UploadAccepts"] = strings.Replace(setting.Repository.Upload.AllowedTypes, "|", ",", -1) + ctx.Data["UploadMaxFiles"] = setting.Repository.Upload.MaxFiles + ctx.Data["UploadMaxSize"] = setting.Repository.Upload.FileMaxSize + } +} diff --git a/modules/upload/upload_test.go b/modules/upload/upload_test.go new file mode 100644 index 00000000000..d258b04f774 --- /dev/null +++ b/modules/upload/upload_test.go @@ -0,0 +1,195 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package upload + +import ( + "bytes" + "compress/gzip" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUpload(t *testing.T) { + testContent := []byte(`This is a plain text file.`) + var b bytes.Buffer + w := gzip.NewWriter(&b) + w.Write(testContent) + w.Close() + + kases := []struct { + data []byte + fileName string + allowedTypes string + err error + }{ + { + data: testContent, + fileName: "test.txt", + allowedTypes: "", + err: nil, + }, + { + data: testContent, + fileName: "dir/test.txt", + allowedTypes: "", + err: nil, + }, + { + data: testContent, + fileName: "../../../test.txt", + allowedTypes: "", + err: nil, + }, + { + data: testContent, + fileName: "test.txt", + allowedTypes: "", + err: nil, + }, + { + data: testContent, + fileName: "test.txt", + allowedTypes: ",", + err: nil, + }, + { + data: testContent, + fileName: "test.txt", + allowedTypes: "|", + err: nil, + }, + { + data: testContent, + fileName: "test.txt", + allowedTypes: "*/*", + err: nil, + }, + { + data: testContent, + fileName: "test.txt", + allowedTypes: "*/*,", + err: nil, + }, + { + data: testContent, + fileName: "test.txt", + allowedTypes: "*/*|", + err: nil, + }, + { + data: testContent, + fileName: "test.txt", + allowedTypes: "text/plain", + err: nil, + }, + { + data: testContent, + fileName: "dir/test.txt", + allowedTypes: "text/plain", + err: nil, + }, + { + data: testContent, + fileName: "/dir.txt/test.js", + allowedTypes: ".js", + err: nil, + }, + { + data: testContent, + fileName: "test.txt", + allowedTypes: " text/plain ", + err: nil, + }, + { + data: testContent, + fileName: "test.txt", + allowedTypes: ".txt", + err: nil, + }, + { + data: testContent, + fileName: "test.txt", + allowedTypes: " .txt,.js", + err: nil, + }, + { + data: testContent, + fileName: "test.txt", + allowedTypes: " .txt|.js", + err: nil, + }, + { + data: testContent, + fileName: "../../test.txt", + allowedTypes: " .txt|.js", + err: nil, + }, + { + data: testContent, + fileName: "test.txt", + allowedTypes: " .txt ,.js ", + err: nil, + }, + { + data: testContent, + fileName: "test.txt", + allowedTypes: "text/plain, .txt", + err: nil, + }, + { + data: testContent, + fileName: "test.txt", + allowedTypes: "text/*", + err: nil, + }, + { + data: testContent, + fileName: "test.txt", + allowedTypes: "text/*,.js", + err: nil, + }, + { + data: testContent, + fileName: "test.txt", + allowedTypes: "text/**", + err: ErrFileTypeForbidden{"text/plain; charset=utf-8"}, + }, + { + data: testContent, + fileName: "test.txt", + allowedTypes: "application/x-gzip", + err: ErrFileTypeForbidden{"text/plain; charset=utf-8"}, + }, + { + data: testContent, + fileName: "test.txt", + allowedTypes: ".zip", + err: ErrFileTypeForbidden{"text/plain; charset=utf-8"}, + }, + { + data: testContent, + fileName: "test.txt", + allowedTypes: ".zip,.txtx", + err: ErrFileTypeForbidden{"text/plain; charset=utf-8"}, + }, + { + data: testContent, + fileName: "test.txt", + allowedTypes: ".zip|.txtx", + err: ErrFileTypeForbidden{"text/plain; charset=utf-8"}, + }, + { + data: b.Bytes(), + fileName: "test.txt", + allowedTypes: "application/x-gzip", + err: nil, + }, + } + + for _, kase := range kases { + assert.Equal(t, kase.err, Verify(kase.data, kase.fileName, kase.allowedTypes)) + } +} diff --git a/routers/api/v1/repo/release_attachment.go b/routers/api/v1/repo/release_attachment.go index 3d1084f211f..f352c108298 100644 --- a/routers/api/v1/repo/release_attachment.go +++ b/routers/api/v1/repo/release_attachment.go @@ -6,7 +6,6 @@ package repo import ( "net/http" - "strings" "code.gitea.io/gitea/models" "code.gitea.io/gitea/modules/context" @@ -182,7 +181,7 @@ func CreateReleaseAttachment(ctx *context.APIContext) { } // Check if the filetype is allowed by the settings - err = upload.VerifyAllowedContentType(buf, strings.Split(setting.Attachment.AllowedTypes, ",")) + err = upload.Verify(buf, header.Filename, setting.Repository.Release.AllowedTypes) if err != nil { ctx.Error(http.StatusBadRequest, "DetectContentType", err) return diff --git a/routers/repo/attachment.go b/routers/repo/attachment.go index 313704bc383..5b699abc8d1 100644 --- a/routers/repo/attachment.go +++ b/routers/repo/attachment.go @@ -7,7 +7,6 @@ package repo import ( "fmt" "net/http" - "strings" "code.gitea.io/gitea/models" "code.gitea.io/gitea/modules/context" @@ -17,16 +16,18 @@ import ( "code.gitea.io/gitea/modules/upload" ) -func renderAttachmentSettings(ctx *context.Context) { - ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled - ctx.Data["AttachmentStoreType"] = setting.Attachment.Storage.Type - ctx.Data["AttachmentAllowedTypes"] = setting.Attachment.AllowedTypes - ctx.Data["AttachmentMaxSize"] = setting.Attachment.MaxSize - ctx.Data["AttachmentMaxFiles"] = setting.Attachment.MaxFiles +// UploadIssueAttachment response for Issue/PR attachments +func UploadIssueAttachment(ctx *context.Context) { + uploadAttachment(ctx, setting.Attachment.AllowedTypes) } -// UploadAttachment response for uploading issue's attachment -func UploadAttachment(ctx *context.Context) { +// UploadReleaseAttachment response for uploading release attachments +func UploadReleaseAttachment(ctx *context.Context) { + uploadAttachment(ctx, setting.Repository.Release.AllowedTypes) +} + +// UploadAttachment response for uploading attachments +func uploadAttachment(ctx *context.Context, allowedTypes string) { if !setting.Attachment.Enabled { ctx.Error(404, "attachment is not enabled") return @@ -45,7 +46,7 @@ func UploadAttachment(ctx *context.Context) { buf = buf[:n] } - err = upload.VerifyAllowedContentType(buf, strings.Split(setting.Attachment.AllowedTypes, ",")) + err = upload.Verify(buf, header.Filename, allowedTypes) if err != nil { ctx.Error(400, err.Error()) return diff --git a/routers/repo/compare.go b/routers/repo/compare.go index 9329b5a1d29..fb6076cbe1f 100644 --- a/routers/repo/compare.go +++ b/routers/repo/compare.go @@ -17,6 +17,7 @@ import ( "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/upload" "code.gitea.io/gitea/services/gitdiff" ) @@ -578,7 +579,8 @@ func CompareDiff(ctx *context.Context) { ctx.Data["RequireSimpleMDE"] = true ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes setTemplateIfExists(ctx, pullRequestTemplateKey, nil, pullRequestTemplateCandidates) - renderAttachmentSettings(ctx) + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWrite(models.UnitTypePullRequests) diff --git a/routers/repo/editor.go b/routers/repo/editor.go index 6a3f379f6a0..aa10bd146ab 100644 --- a/routers/repo/editor.go +++ b/routers/repo/editor.go @@ -494,18 +494,12 @@ func DeleteFilePost(ctx *context.Context, form auth.DeleteRepoFileForm) { } } -func renderUploadSettings(ctx *context.Context) { - ctx.Data["RequireTribute"] = true - ctx.Data["RequireSimpleMDE"] = true - ctx.Data["UploadAllowedTypes"] = strings.Join(setting.Repository.Upload.AllowedTypes, ",") - ctx.Data["UploadMaxSize"] = setting.Repository.Upload.FileMaxSize - ctx.Data["UploadMaxFiles"] = setting.Repository.Upload.MaxFiles -} - // UploadFile render upload file page func UploadFile(ctx *context.Context) { ctx.Data["PageIsUpload"] = true - renderUploadSettings(ctx) + ctx.Data["RequireTribute"] = true + ctx.Data["RequireSimpleMDE"] = true + upload.AddUploadContext(ctx, "repo") canCommit := renderCommitRights(ctx) treePath := cleanUploadFileName(ctx.Repo.TreePath) if treePath != ctx.Repo.TreePath { @@ -538,7 +532,9 @@ func UploadFile(ctx *context.Context) { // UploadFilePost response for uploading file func UploadFilePost(ctx *context.Context, form auth.UploadRepoFileForm) { ctx.Data["PageIsUpload"] = true - renderUploadSettings(ctx) + ctx.Data["RequireTribute"] = true + ctx.Data["RequireSimpleMDE"] = true + upload.AddUploadContext(ctx, "repo") canCommit := renderCommitRights(ctx) oldBranchName := ctx.Repo.BranchName @@ -704,12 +700,10 @@ func UploadFileToServer(ctx *context.Context) { buf = buf[:n] } - if len(setting.Repository.Upload.AllowedTypes) > 0 { - err = upload.VerifyAllowedContentType(buf, setting.Repository.Upload.AllowedTypes) - if err != nil { - ctx.Error(400, err.Error()) - return - } + err = upload.Verify(buf, header.Filename, setting.Repository.Upload.AllowedTypes) + if err != nil { + ctx.Error(400, err.Error()) + return } name := cleanUploadFileName(header.Filename) diff --git a/routers/repo/issue.go b/routers/repo/issue.go index be46ddbeb93..f44e88fc4b9 100644 --- a/routers/repo/issue.go +++ b/routers/repo/issue.go @@ -26,6 +26,7 @@ import ( "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/upload" "code.gitea.io/gitea/modules/util" comment_service "code.gitea.io/gitea/services/comments" issue_service "code.gitea.io/gitea/services/issue" @@ -573,6 +574,8 @@ func NewIssue(ctx *context.Context) { body := ctx.Query("body") ctx.Data["BodyQuery"] = body ctx.Data["IsProjectsEnabled"] = ctx.Repo.CanRead(models.UnitTypeProjects) + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") milestoneID := ctx.QueryInt64("milestone") if milestoneID > 0 { @@ -599,8 +602,6 @@ func NewIssue(ctx *context.Context) { } - renderAttachmentSettings(ctx) - RetrieveRepoMetas(ctx, ctx.Repo.Repository, false) setTemplateIfExists(ctx, issueTemplateKey, context.IssueTemplateDirCandidates, IssueTemplateCandidates) if ctx.Written() { @@ -731,7 +732,8 @@ func NewIssuePost(ctx *context.Context, form auth.CreateIssueForm) { ctx.Data["RequireSimpleMDE"] = true ctx.Data["ReadOnly"] = false ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes - renderAttachmentSettings(ctx) + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") var ( repo = ctx.Repo.Repository @@ -880,8 +882,8 @@ func ViewIssue(ctx *context.Context) { ctx.Data["RequireTribute"] = true ctx.Data["RequireSimpleMDE"] = true ctx.Data["IsProjectsEnabled"] = ctx.Repo.CanRead(models.UnitTypeProjects) - - renderAttachmentSettings(ctx) + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") if err = issue.LoadAttributes(); err != nil { ctx.ServerError("LoadAttributes", err) diff --git a/routers/repo/pull.go b/routers/repo/pull.go index a6f7a70744a..535bd0cdb54 100644 --- a/routers/repo/pull.go +++ b/routers/repo/pull.go @@ -24,6 +24,7 @@ import ( "code.gitea.io/gitea/modules/notification" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/upload" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/utils" "code.gitea.io/gitea/services/gitdiff" @@ -892,7 +893,8 @@ func CompareAndPullRequestPost(ctx *context.Context, form auth.CreateIssueForm) ctx.Data["IsDiffCompare"] = true ctx.Data["RequireHighlightJS"] = true ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes - renderAttachmentSettings(ctx) + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") var ( repo = ctx.Repo.Repository diff --git a/routers/repo/release.go b/routers/repo/release.go index 8cd46e850db..ab251ec755c 100644 --- a/routers/repo/release.go +++ b/routers/repo/release.go @@ -16,6 +16,7 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/upload" releaseservice "code.gitea.io/gitea/services/release" ) @@ -192,7 +193,8 @@ func NewRelease(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.release.new_release") ctx.Data["PageIsReleaseList"] = true ctx.Data["tag_target"] = ctx.Repo.Repository.DefaultBranch - renderAttachmentSettings(ctx) + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "release") ctx.HTML(200, tplReleaseNew) } @@ -278,7 +280,8 @@ func EditRelease(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.release.edit_release") ctx.Data["PageIsReleaseList"] = true ctx.Data["PageIsEditRelease"] = true - renderAttachmentSettings(ctx) + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "release") tagName := ctx.Params("*") rel, err := models.GetRelease(ctx.Repo.Repository.ID, tagName) diff --git a/routers/routes/routes.go b/routers/routes/routes.go index f60af5dad04..97f4e5aeaf8 100644 --- a/routers/routes/routes.go +++ b/routers/routes/routes.go @@ -512,11 +512,6 @@ func RegisterRoutes(m *macaron.Macaron) { m.Get("/attachments/:uuid", repo.GetAttachment) }, ignSignIn) - m.Group("/attachments", func() { - m.Post("", repo.UploadAttachment) - m.Post("/delete", repo.DeleteAttachment) - }, reqSignIn) - m.Group("/:username", func() { m.Post("/action/:action", user.Action) }, reqSignIn) @@ -754,8 +749,11 @@ func RegisterRoutes(m *macaron.Macaron) { m.Post("/reactions/:action", bindIgnErr(auth.ReactionForm{}), repo.ChangeIssueReaction) m.Post("/lock", reqRepoIssueWriter, bindIgnErr(auth.IssueLockForm{}), repo.LockIssue) m.Post("/unlock", reqRepoIssueWriter, repo.UnlockIssue) - m.Get("/attachments", repo.GetIssueAttachments) }, context.RepoMustNotBeArchived()) + m.Group("/:index", func() { + m.Get("/attachments", repo.GetIssueAttachments) + m.Get("/attachments/:uuid", repo.GetAttachment) + }) m.Post("/labels", reqRepoIssuesOrPullsWriter, repo.UpdateIssueLabel) m.Post("/milestone", reqRepoIssuesOrPullsWriter, repo.UpdateIssueMilestone) @@ -764,13 +762,17 @@ func RegisterRoutes(m *macaron.Macaron) { m.Post("/request_review", reqRepoIssuesOrPullsReader, repo.UpdatePullReviewRequest) m.Post("/status", reqRepoIssuesOrPullsWriter, repo.UpdateIssueStatus) m.Post("/resolve_conversation", reqRepoIssuesOrPullsReader, repo.UpdateResolveConversation) + m.Post("/attachments", repo.UploadIssueAttachment) + m.Post("/attachments/remove", repo.DeleteAttachment) }, context.RepoMustNotBeArchived()) m.Group("/comments/:id", func() { m.Post("", repo.UpdateCommentContent) m.Post("/delete", repo.DeleteComment) m.Post("/reactions/:action", bindIgnErr(auth.ReactionForm{}), repo.ChangeCommentReaction) - m.Get("/attachments", repo.GetCommentAttachments) }, context.RepoMustNotBeArchived()) + m.Group("/comments/:id", func() { + m.Get("/attachments", repo.GetCommentAttachments) + }) m.Group("/labels", func() { m.Post("/new", bindIgnErr(auth.CreateLabelForm{}), repo.NewLabel) m.Post("/edit", bindIgnErr(auth.CreateLabelForm{}), repo.UpdateLabel) @@ -826,11 +828,14 @@ func RegisterRoutes(m *macaron.Macaron) { m.Get("/", repo.Releases) m.Get("/tag/*", repo.SingleRelease) m.Get("/latest", repo.LatestRelease) + m.Get("/attachments/:uuid", repo.GetAttachment) }, repo.MustBeNotEmpty, context.RepoRefByType(context.RepoRefTag)) m.Group("/releases", func() { m.Get("/new", repo.NewRelease) m.Post("/new", bindIgnErr(auth.NewReleaseForm{}), repo.NewReleasePost) m.Post("/delete", repo.DeleteRelease) + m.Post("/attachments", repo.UploadReleaseAttachment) + m.Post("/attachments/remove", repo.DeleteAttachment) }, reqSignIn, repo.MustBeNotEmpty, context.RepoMustNotBeArchived(), reqRepoReleaseWriter, context.RepoRef()) m.Group("/releases", func() { m.Get("/edit/*", repo.EditRelease) diff --git a/templates/repo/editor/upload.tmpl b/templates/repo/editor/upload.tmpl index 8138194e1b2..6ad7c7445fe 100644 --- a/templates/repo/editor/upload.tmpl +++ b/templates/repo/editor/upload.tmpl @@ -27,7 +27,7 @@
-
+ {{template "repo/upload" .}}
{{template "repo/editor/commit_form" .}} diff --git a/templates/repo/issue/comment_tab.tmpl b/templates/repo/issue/comment_tab.tmpl index 77537edf739..d24f7a22ad3 100644 --- a/templates/repo/issue/comment_tab.tmpl +++ b/templates/repo/issue/comment_tab.tmpl @@ -12,8 +12,8 @@ {{if .IsAttachmentEnabled}} -
-
-
-
+
+
+ {{template "repo/upload" .}} +
{{end}} diff --git a/templates/repo/issue/view_content.tmpl b/templates/repo/issue/view_content.tmpl index 1addbaf5bb0..a4ce0a71060 100644 --- a/templates/repo/issue/view_content.tmpl +++ b/templates/repo/issue/view_content.tmpl @@ -197,19 +197,10 @@ {{if .IsAttachmentEnabled}} -
-
-
+
+
+ {{template "repo/upload" .}}
-
{{end}}
diff --git a/templates/repo/upload.tmpl b/templates/repo/upload.tmpl new file mode 100644 index 00000000000..93bc098daed --- /dev/null +++ b/templates/repo/upload.tmpl @@ -0,0 +1,13 @@ +
diff --git a/web_src/js/index.js b/web_src/js/index.js index 415db385b39..e4f15753916 100644 --- a/web_src/js/index.js +++ b/web_src/js/index.js @@ -326,7 +326,7 @@ function uploadFile(file, callback) { } }); - xhr.open('post', `${AppSubUrl}/attachments`, true); + xhr.open('post', $('#dropzone').data('upload-url'), true); xhr.setRequestHeader('X-Csrf-Token', csrf); const formData = new FormData(); formData.append('file', file, file.name); @@ -902,7 +902,7 @@ async function initRepository() { headers: {'X-Csrf-Token': csrf}, maxFiles: $dropzone.data('max-file'), maxFilesize: $dropzone.data('max-size'), - acceptedFiles: ($dropzone.data('accepts') === '*/*') ? null : $dropzone.data('accepts'), + acceptedFiles: (['*/*', ''].includes($dropzone.data('accepts'))) ? null : $dropzone.data('accepts'), addRemoveLinks: true, dictDefaultMessage: $dropzone.data('default-message'), dictInvalidFileType: $dropzone.data('invalid-input-type'), @@ -923,10 +923,10 @@ async function initRepository() { return; } $(`#${filenameDict[file.name].uuid}`).remove(); - if ($dropzone.data('remove-url') && $dropzone.data('csrf') && !filenameDict[file.name].submitted) { + if ($dropzone.data('remove-url') && !filenameDict[file.name].submitted) { $.post($dropzone.data('remove-url'), { file: filenameDict[file.name].uuid, - _csrf: $dropzone.data('csrf') + _csrf: csrf, }); } }); @@ -2323,7 +2323,7 @@ $(document).ready(async () => { headers: {'X-Csrf-Token': csrf}, maxFiles: $dropzone.data('max-file'), maxFilesize: $dropzone.data('max-size'), - acceptedFiles: ($dropzone.data('accepts') === '*/*') ? null : $dropzone.data('accepts'), + acceptedFiles: (['*/*', ''].includes($dropzone.data('accepts'))) ? null : $dropzone.data('accepts'), addRemoveLinks: true, dictDefaultMessage: $dropzone.data('default-message'), dictInvalidFileType: $dropzone.data('invalid-input-type'), @@ -2340,10 +2340,10 @@ $(document).ready(async () => { if (file.name in filenameDict) { $(`#${filenameDict[file.name]}`).remove(); } - if ($dropzone.data('remove-url') && $dropzone.data('csrf')) { + if ($dropzone.data('remove-url')) { $.post($dropzone.data('remove-url'), { file: filenameDict[file.name], - _csrf: $dropzone.data('csrf') + _csrf: csrf }); } });