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