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 `<input type="file">` - 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 <lauris@nix.lv>tokarchuk/v1.17
parent
67a5573310
commit
cda44750cb
@ -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} |
|
||||||
} |
|
@ -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)) |
|
||||||
} |
|
||||||
} |
|
@ -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 |
||||||
|
} |
||||||
|
} |
@ -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)) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,13 @@ |
|||||||
|
<div |
||||||
|
class="ui dropzone" |
||||||
|
id="dropzone" |
||||||
|
data-upload-url="{{.UploadUrl}}" |
||||||
|
data-remove-url="{{.UploadRemoveUrl}}" |
||||||
|
data-accepts="{{.UploadAccepts}}" |
||||||
|
data-max-file="{{.UploadMaxFiles}}" |
||||||
|
data-max-size="{{.UploadMaxSize}}" |
||||||
|
data-default-message="{{.i18n.Tr "dropzone.default_message"}}" |
||||||
|
data-invalid-input-type="{{.i18n.Tr "dropzone.invalid_input_type"}}" |
||||||
|
data-file-too-big="{{.i18n.Tr "dropzone.file_too_big"}}" |
||||||
|
data-remove-file="{{.i18n.Tr "dropzone.remove_file"}}" |
||||||
|
></div> |
Loading…
Reference in new issue