mirror of
https://github.com/grafana/grafana.git
synced 2025-01-27 16:57:14 -06:00
Storage: Mime type detection (#52512)
* Storage: implement mime type detection * lint
This commit is contained in:
parent
1e3135b18a
commit
d9db155394
62
pkg/services/store/go-is-svg/svg.go
Normal file
62
pkg/services/store/go-is-svg/svg.go
Normal file
@ -0,0 +1,62 @@
|
||||
// The MIT License
|
||||
//
|
||||
//Copyright (c) 2016 Tomas Aparicio
|
||||
//
|
||||
//Permission is hereby granted, free of charge, to any person
|
||||
//obtaining a copy of this software and associated documentation
|
||||
//files (the "Software"), to deal in the Software without
|
||||
//restriction, including without limitation the rights to use,
|
||||
//copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
//copies of the Software, and to permit persons to whom the
|
||||
//Software is furnished to do so, subject to the following
|
||||
//conditions:
|
||||
//
|
||||
//The above copyright notice and this permission notice shall be
|
||||
//included in all copies or substantial portions of the Software.
|
||||
//
|
||||
//THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
//EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
//OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
//NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
//HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
//WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
//FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
//OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
package issvg
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var (
|
||||
// nolint:gosimple
|
||||
htmlCommentRegex = regexp.MustCompile("(?i)<!--([\\s\\S]*?)-->")
|
||||
svgRegex = regexp.MustCompile(`(?i)^\s*(?:<\?xml[^>]*>\s*)?(?:<!doctype svg[^>]*>\s*)?<svg[^>]*>[^*]*<\/svg>\s*$`)
|
||||
)
|
||||
|
||||
// isBinary checks if the given buffer is a binary file.
|
||||
func isBinary(buf []byte) bool {
|
||||
if len(buf) < 24 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < 24; i++ {
|
||||
charCode, _ := utf8.DecodeRuneInString(string(buf[i]))
|
||||
if charCode == 65533 || charCode <= 8 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Is returns true if the given buffer is a valid SVG image.
|
||||
func Is(buf []byte) bool {
|
||||
return !isBinary(buf) && svgRegex.Match(htmlCommentRegex.ReplaceAll(buf, []byte{}))
|
||||
}
|
||||
|
||||
// IsSVG returns true if the given buffer is a valid SVG image.
|
||||
// Alias to: Is()
|
||||
func IsSVG(buf []byte) bool {
|
||||
return Is(buf)
|
||||
}
|
@ -129,7 +129,6 @@ func (s *httpStorage) Upload(c *models.ReqContext) response.Response {
|
||||
|
||||
err = s.store.Upload(c.Req.Context(), c.SignedInUser, &UploadRequest{
|
||||
Contents: data,
|
||||
MimeType: mimeType,
|
||||
EntityType: entityType,
|
||||
Path: path,
|
||||
OverwriteExistingFile: overwriteExistingFile,
|
||||
|
@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mime"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/filestorage"
|
||||
@ -41,10 +42,17 @@ func (s *standardStorageService) sanitizeUploadRequest(ctx context.Context, user
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// we have already validated that the file contents match the extension in `./validate.go`
|
||||
mimeType := mime.TypeByExtension(filepath.Ext(req.Path))
|
||||
if mimeType == "" {
|
||||
grafanaStorageLogger.Info("failed to find mime type", "path", req.Path)
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
|
||||
return &filestorage.UpsertFileCommand{
|
||||
Path: storagePath,
|
||||
Contents: contents,
|
||||
MimeType: req.MimeType,
|
||||
MimeType: mimeType,
|
||||
CacheControl: req.CacheControl,
|
||||
ContentDisposition: req.ContentDisposition,
|
||||
Properties: req.Properties,
|
||||
|
@ -194,7 +194,9 @@ func ProvideService(sql *sqlstore.SQLStore, features featuremgmt.FeatureToggles,
|
||||
}
|
||||
})
|
||||
|
||||
return newStandardStorageService(sql, globalRoots, initializeOrgStorages, authService)
|
||||
return newStandardStorageService(sql, globalRoots, initializeOrgStorages, authService, storageServiceConfig{
|
||||
allowUnsanitizedSvgUpload: false,
|
||||
})
|
||||
}
|
||||
|
||||
func createSystemBrandingPathFilter() filestorage.PathFilter {
|
||||
@ -205,7 +207,7 @@ func createSystemBrandingPathFilter() filestorage.PathFilter {
|
||||
nil)
|
||||
}
|
||||
|
||||
func newStandardStorageService(sql *sqlstore.SQLStore, globalRoots []storageRuntime, initializeOrgStorages func(orgId int64) []storageRuntime, authService storageAuthService) *standardStorageService {
|
||||
func newStandardStorageService(sql *sqlstore.SQLStore, globalRoots []storageRuntime, initializeOrgStorages func(orgId int64) []storageRuntime, authService storageAuthService, cfg storageServiceConfig) *standardStorageService {
|
||||
rootsByOrgId := make(map[int64][]storageRuntime)
|
||||
rootsByOrgId[ac.GlobalOrgID] = globalRoots
|
||||
|
||||
@ -218,9 +220,7 @@ func newStandardStorageService(sql *sqlstore.SQLStore, globalRoots []storageRunt
|
||||
sql: sql,
|
||||
tree: res,
|
||||
authService: authService,
|
||||
cfg: storageServiceConfig{
|
||||
allowUnsanitizedSvgUpload: false,
|
||||
},
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
@ -252,7 +252,6 @@ func (s *standardStorageService) Read(ctx context.Context, user *models.SignedIn
|
||||
|
||||
type UploadRequest struct {
|
||||
Contents []byte
|
||||
MimeType string // TODO: remove MimeType from the struct once we can infer it from file contents
|
||||
Path string
|
||||
CacheControl string
|
||||
ContentDisposition string
|
||||
@ -279,17 +278,17 @@ func (s *standardStorageService) Upload(ctx context.Context, user *models.Signed
|
||||
|
||||
validationResult := s.validateUploadRequest(ctx, user, req, storagePath)
|
||||
if !validationResult.ok {
|
||||
grafanaStorageLogger.Warn("file upload validation failed", "filetype", req.MimeType, "path", req.Path, "reason", validationResult.reason)
|
||||
grafanaStorageLogger.Warn("file upload validation failed", "path", req.Path, "reason", validationResult.reason)
|
||||
return ErrValidationFailed
|
||||
}
|
||||
|
||||
upsertCommand, err := s.sanitizeUploadRequest(ctx, user, req, storagePath)
|
||||
if err != nil {
|
||||
grafanaStorageLogger.Error("failed while sanitizing the upload request", "filetype", req.MimeType, "path", req.Path, "error", err)
|
||||
grafanaStorageLogger.Error("failed while sanitizing the upload request", "path", req.Path, "error", err)
|
||||
return ErrUploadInternalError
|
||||
}
|
||||
|
||||
grafanaStorageLogger.Info("uploading a file", "filetype", req.MimeType, "path", req.Path)
|
||||
grafanaStorageLogger.Info("uploading a file", "path", req.Path)
|
||||
|
||||
if !req.OverwriteExistingFile {
|
||||
file, err := root.Store().Get(ctx, storagePath)
|
||||
|
@ -3,6 +3,7 @@ package store
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io/ioutil"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
@ -16,6 +17,9 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
htmlBytes, _ = ioutil.ReadFile("testdata/page.html")
|
||||
jpgBytes, _ = ioutil.ReadFile("testdata/image.jpg")
|
||||
svgBytes, _ = ioutil.ReadFile("testdata/image.svg")
|
||||
dummyUser = &models.SignedInUser{OrgId: 1}
|
||||
allowAllAuthService = newStaticStorageAuthService(func(ctx context.Context, user *models.SignedInUser, storageName string) map[string]filestorage.PathFilter {
|
||||
return map[string]filestorage.PathFilter{
|
||||
@ -53,7 +57,7 @@ func TestListFiles(t *testing.T) {
|
||||
|
||||
store := newStandardStorageService(sqlstore.InitTestDB(t), roots, func(orgId int64) []storageRuntime {
|
||||
return make([]storageRuntime, 0)
|
||||
}, allowAllAuthService)
|
||||
}, allowAllAuthService, storageServiceConfig{})
|
||||
frame, err := store.List(context.Background(), dummyUser, "public/testdata")
|
||||
require.NoError(t, err)
|
||||
|
||||
@ -73,7 +77,7 @@ func TestListFilesWithoutPermissions(t *testing.T) {
|
||||
|
||||
store := newStandardStorageService(sqlstore.InitTestDB(t), roots, func(orgId int64) []storageRuntime {
|
||||
return make([]storageRuntime, 0)
|
||||
}, denyAllAuthService)
|
||||
}, denyAllAuthService, storageServiceConfig{})
|
||||
frame, err := store.List(context.Background(), dummyUser, "public/testdata")
|
||||
require.NoError(t, err)
|
||||
rowLen, err := frame.RowLen()
|
||||
@ -98,7 +102,7 @@ func setupUploadStore(t *testing.T, authService storageAuthService) (StorageServ
|
||||
}
|
||||
store := newStandardStorageService(sqlstore.InitTestDB(t), []storageRuntime{sqlStorage}, func(orgId int64) []storageRuntime {
|
||||
return make([]storageRuntime, 0)
|
||||
}, authService)
|
||||
}, authService, storageServiceConfig{allowUnsanitizedSvgUpload: true})
|
||||
|
||||
return store, mockStorage, storageName
|
||||
}
|
||||
@ -106,14 +110,18 @@ func setupUploadStore(t *testing.T, authService storageAuthService) (StorageServ
|
||||
func TestShouldUploadWhenNoFileAlreadyExists(t *testing.T) {
|
||||
service, mockStorage, storageName := setupUploadStore(t, nil)
|
||||
|
||||
mockStorage.On("Get", mock.Anything, "/myFile.jpg").Return(nil, nil)
|
||||
mockStorage.On("Upsert", mock.Anything, mock.Anything).Return(nil)
|
||||
fileName := "/myFile.jpg"
|
||||
mockStorage.On("Get", mock.Anything, fileName).Return(nil, nil)
|
||||
mockStorage.On("Upsert", mock.Anything, &filestorage.UpsertFileCommand{
|
||||
Path: fileName,
|
||||
MimeType: "image/jpeg",
|
||||
Contents: jpgBytes,
|
||||
}).Return(nil)
|
||||
|
||||
err := service.Upload(context.Background(), dummyUser, &UploadRequest{
|
||||
EntityType: EntityTypeImage,
|
||||
Contents: make([]byte, 0),
|
||||
Path: storageName + "/myFile.jpg",
|
||||
MimeType: "image/jpg",
|
||||
Contents: jpgBytes,
|
||||
Path: storageName + fileName,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@ -123,9 +131,8 @@ func TestShouldFailUploadWithoutAccess(t *testing.T) {
|
||||
|
||||
err := service.Upload(context.Background(), dummyUser, &UploadRequest{
|
||||
EntityType: EntityTypeImage,
|
||||
Contents: make([]byte, 0),
|
||||
Contents: jpgBytes,
|
||||
Path: storageName + "/myFile.jpg",
|
||||
MimeType: "image/jpg",
|
||||
})
|
||||
require.ErrorIs(t, err, ErrAccessDenied)
|
||||
}
|
||||
@ -137,9 +144,8 @@ func TestShouldFailUploadWhenFileAlreadyExists(t *testing.T) {
|
||||
|
||||
err := service.Upload(context.Background(), dummyUser, &UploadRequest{
|
||||
EntityType: EntityTypeImage,
|
||||
Contents: make([]byte, 0),
|
||||
Contents: jpgBytes,
|
||||
Path: storageName + "/myFile.jpg",
|
||||
MimeType: "image/jpg",
|
||||
})
|
||||
require.ErrorIs(t, err, ErrFileAlreadyExists)
|
||||
}
|
||||
@ -173,3 +179,50 @@ func TestShouldDelegateFolderDeletion(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestShouldUploadSvg(t *testing.T) {
|
||||
service, mockStorage, storageName := setupUploadStore(t, nil)
|
||||
|
||||
fileName := "/myFile.svg"
|
||||
mockStorage.On("Get", mock.Anything, fileName).Return(nil, nil)
|
||||
mockStorage.On("Upsert", mock.Anything, &filestorage.UpsertFileCommand{
|
||||
Path: fileName,
|
||||
MimeType: "image/svg+xml",
|
||||
Contents: svgBytes,
|
||||
}).Return(nil)
|
||||
|
||||
err := service.Upload(context.Background(), dummyUser, &UploadRequest{
|
||||
EntityType: EntityTypeImage,
|
||||
Contents: svgBytes,
|
||||
Path: storageName + fileName,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestShouldNotUploadHtmlDisguisedAsSvg(t *testing.T) {
|
||||
service, mockStorage, storageName := setupUploadStore(t, nil)
|
||||
|
||||
fileName := "/myFile.svg"
|
||||
mockStorage.On("Get", mock.Anything, fileName).Return(nil, nil)
|
||||
|
||||
err := service.Upload(context.Background(), dummyUser, &UploadRequest{
|
||||
EntityType: EntityTypeImage,
|
||||
Contents: htmlBytes,
|
||||
Path: storageName + fileName,
|
||||
})
|
||||
require.ErrorIs(t, err, ErrValidationFailed)
|
||||
}
|
||||
|
||||
func TestShouldNotUploadJpgDisguisedAsSvg(t *testing.T) {
|
||||
service, mockStorage, storageName := setupUploadStore(t, nil)
|
||||
|
||||
fileName := "/myFile.svg"
|
||||
mockStorage.On("Get", mock.Anything, fileName).Return(nil, nil)
|
||||
|
||||
err := service.Upload(context.Background(), dummyUser, &UploadRequest{
|
||||
EntityType: EntityTypeImage,
|
||||
Contents: jpgBytes,
|
||||
Path: storageName + fileName,
|
||||
})
|
||||
require.ErrorIs(t, err, ErrValidationFailed)
|
||||
}
|
||||
|
BIN
pkg/services/store/testdata/image.jpg
vendored
Normal file
BIN
pkg/services/store/testdata/image.jpg
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 134 B |
1
pkg/services/store/testdata/image.svg
vendored
Normal file
1
pkg/services/store/testdata/image.svg
vendored
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12,2A10,10,0,1,0,22,12,10,10,0,0,0,12,2Zm0,18a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"/></svg>
|
After Width: | Height: | Size: 156 B |
9
pkg/services/store/testdata/page.html
vendored
Normal file
9
pkg/services/store/testdata/page.html
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>My First Heading</h1>
|
||||
|
||||
<p>My first paragraph.</p>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -3,10 +3,14 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/filestorage"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
issvg "github.com/grafana/grafana/pkg/services/store/go-is-svg"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -24,7 +28,7 @@ var (
|
||||
".gif": {"image/gif": true},
|
||||
".png": {"image/png": true},
|
||||
".webp": {"image/webp": true},
|
||||
".svg": {"text/xml; charset=utf-8": true, "text/plain; charset=utf-8": true, "image/svg+xml": true},
|
||||
".svg": {"image/svg+xml": true},
|
||||
}
|
||||
)
|
||||
|
||||
@ -47,19 +51,24 @@ func fail(reason string) validationResult {
|
||||
}
|
||||
|
||||
func (s *standardStorageService) detectMimeType(ctx context.Context, user *models.SignedInUser, uploadRequest *UploadRequest) string {
|
||||
// TODO: implement a spoofing-proof MimeType detection based on the contents
|
||||
return uploadRequest.MimeType
|
||||
if strings.HasSuffix(uploadRequest.Path, ".svg") {
|
||||
if issvg.IsSVG(uploadRequest.Contents) {
|
||||
return "image/svg+xml"
|
||||
}
|
||||
}
|
||||
|
||||
return http.DetectContentType(uploadRequest.Contents)
|
||||
}
|
||||
|
||||
func (s *standardStorageService) validateImage(ctx context.Context, user *models.SignedInUser, uploadRequest *UploadRequest) validationResult {
|
||||
ext := filepath.Ext(uploadRequest.Path)
|
||||
if !allowedImageExtensions[ext] {
|
||||
return fail("unsupported extension")
|
||||
return fail(fmt.Sprintf("unsupported extension: %s", ext))
|
||||
}
|
||||
|
||||
mimeType := s.detectMimeType(ctx, user, uploadRequest)
|
||||
if !imageExtensionsToMatchingMimeTypes[ext][mimeType] {
|
||||
return fail("mismatched extension and file contents")
|
||||
return fail(fmt.Sprintf("extension '%s' does not match the detected MimeType: %s", ext, mimeType))
|
||||
}
|
||||
|
||||
return success()
|
||||
@ -70,7 +79,7 @@ func (s *standardStorageService) validateUploadRequest(ctx context.Context, user
|
||||
// TODO: validateProperties
|
||||
|
||||
if err := filestorage.ValidatePath(storagePath); err != nil {
|
||||
return fail("path validation failed. error:" + err.Error() + ". path: " + storagePath)
|
||||
return fail(fmt.Sprintf("path validation failed. error: %s. path: %s", err.Error(), storagePath))
|
||||
}
|
||||
|
||||
switch req.EntityType {
|
||||
|
Loading…
Reference in New Issue
Block a user