Plugins: Fs: Add option to access unallowed files in dev mode (#66492)

* Plugins: Fs: Add option to access unallowed files in dev mode

* Plugins: Fs: allow accessing unallowed files only when in dev mode

* Plugins: Fs: Add ProvideLocalFinder

* Plugins: FS: Pass whole config in NewLocalFinder()

* Plugins: FS: Add AllowListLocalFS

* Plugins: FS: Fix some tests

* Plugins: FS: Update tests

* Plugins: FS: Removed dead code

* Plugins: FS: Add tests for AllowListFS

* Plugins: FS: Update comments

* Plugins: FS: Use variadic arguments for allow list rather than map

* Plugins: FS: Remove unnecessary log

* Plugins: FS: Do not escape plugin root dir

* Fix merge conflict

* Plugins: FS: Update comments

* Plugins: FS: PR review changes

* Fix merge conflict

* Fix tests

* Cleanup

* Fix flaky test

* Changes from PR review

* Lint

* Add comment to LocalFS.Remove

* Fix Windows

* Renamed devMode to production
This commit is contained in:
Giuseppe Guerra
2023-04-27 10:26:15 +02:00
committed by GitHub
parent dfc99cdd19
commit bfe1b68800
14 changed files with 576 additions and 366 deletions

View File

@ -66,7 +66,7 @@ func TestCallResource(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
reg := registry.ProvideService() reg := registry.ProvideService()
l := loader.ProvideService(pCfg, fakes.NewFakeLicensingService(), signature.NewUnsignedAuthorizer(pCfg), l := loader.ProvideService(pCfg, fakes.NewFakeLicensingService(), signature.NewUnsignedAuthorizer(pCfg),
reg, provider.ProvideService(coreRegistry), finder.NewLocalFinder(), fakes.NewFakeRoleRegistry(), reg, provider.ProvideService(coreRegistry), finder.NewLocalFinder(pCfg), fakes.NewFakeRoleRegistry(),
assetpath.ProvideService(pluginscdn.ProvideService(pCfg)), signature.ProvideService(pCfg, keystore.ProvideService(kvstore.NewFakeKVStore()))) assetpath.ProvideService(pluginscdn.ProvideService(pCfg)), signature.ProvideService(pCfg, keystore.ProvideService(kvstore.NewFakeKVStore())))
srcs := sources.ProvideService(cfg, pCfg) srcs := sources.ProvideService(cfg, pCfg)
ps, err := store.ProvideService(reg, srcs, l) ps, err := store.ProvideService(reg, srcs, l)

View File

@ -277,7 +277,7 @@ func Test_GetPluginAssets(t *testing.T) {
requestedFile := filepath.Clean(tmpFile.Name()) requestedFile := filepath.Clean(tmpFile.Name())
t.Run("Given a request for an existing plugin file", func(t *testing.T) { t.Run("Given a request for an existing plugin file", func(t *testing.T) {
p := createPlugin(plugins.JSONData{ID: pluginID}, plugins.External, plugins.NewLocalFS(map[string]struct{}{requestedFile: {}}, filepath.Dir(requestedFile))) p := createPlugin(plugins.JSONData{ID: pluginID}, plugins.External, plugins.NewLocalFS(filepath.Dir(requestedFile)))
pluginRegistry := &fakes.FakePluginRegistry{ pluginRegistry := &fakes.FakePluginRegistry{
Store: map[string]*plugins.Plugin{ Store: map[string]*plugins.Plugin{
p.ID: p, p.ID: p,
@ -295,7 +295,7 @@ func Test_GetPluginAssets(t *testing.T) {
}) })
t.Run("Given a request for a relative path", func(t *testing.T) { t.Run("Given a request for a relative path", func(t *testing.T) {
p := createPlugin(plugins.JSONData{ID: pluginID}, plugins.External, plugins.NewLocalFS(map[string]struct{}{}, "")) p := createPlugin(plugins.JSONData{ID: pluginID}, plugins.External, plugins.NewFakeFS())
pluginRegistry := &fakes.FakePluginRegistry{ pluginRegistry := &fakes.FakePluginRegistry{
Store: map[string]*plugins.Plugin{ Store: map[string]*plugins.Plugin{
p.ID: p, p.ID: p,
@ -312,9 +312,7 @@ func Test_GetPluginAssets(t *testing.T) {
}) })
t.Run("Given a request for an existing plugin file that is not listed as a signature covered file", func(t *testing.T) { t.Run("Given a request for an existing plugin file that is not listed as a signature covered file", func(t *testing.T) {
p := createPlugin(plugins.JSONData{ID: pluginID}, plugins.Core, plugins.NewLocalFS(map[string]struct{}{ p := createPlugin(plugins.JSONData{ID: pluginID}, plugins.Core, plugins.NewLocalFS(filepath.Dir(requestedFile)))
requestedFile: {},
}, ""))
pluginRegistry := &fakes.FakePluginRegistry{ pluginRegistry := &fakes.FakePluginRegistry{
Store: map[string]*plugins.Plugin{ Store: map[string]*plugins.Plugin{
p.ID: p, p.ID: p,
@ -332,7 +330,7 @@ func Test_GetPluginAssets(t *testing.T) {
}) })
t.Run("Given a request for an non-existing plugin file", func(t *testing.T) { t.Run("Given a request for an non-existing plugin file", func(t *testing.T) {
p := createPlugin(plugins.JSONData{ID: pluginID}, plugins.External, plugins.NewLocalFS(map[string]struct{}{}, "")) p := createPlugin(plugins.JSONData{ID: pluginID}, plugins.External, plugins.NewFakeFS())
service := &fakes.FakePluginRegistry{ service := &fakes.FakePluginRegistry{
Store: map[string]*plugins.Plugin{ Store: map[string]*plugins.Plugin{
p.ID: p, p.ID: p,
@ -625,13 +623,13 @@ func Test_PluginsList_AccessControl(t *testing.T) {
ID: "test-app", Type: "app", Name: "test-app", ID: "test-app", Type: "app", Name: "test-app",
Info: plugins.Info{ Info: plugins.Info{
Version: "1.0.0", Version: "1.0.0",
}}, plugins.External, plugins.NewLocalFS(map[string]struct{}{}, "")) }}, plugins.External, plugins.NewFakeFS())
p2 := createPlugin( p2 := createPlugin(
plugins.JSONData{ID: "mysql", Type: "datasource", Name: "MySQL", plugins.JSONData{ID: "mysql", Type: "datasource", Name: "MySQL",
Info: plugins.Info{ Info: plugins.Info{
Author: plugins.InfoLink{Name: "Grafana Labs", URL: "https://grafana.com"}, Author: plugins.InfoLink{Name: "Grafana Labs", URL: "https://grafana.com"},
Description: "Data source for MySQL databases", Description: "Data source for MySQL databases",
}}, plugins.Core, plugins.NewLocalFS(map[string]struct{}{}, "")) }}, plugins.Core, plugins.NewFakeFS())
pluginRegistry := &fakes.FakePluginRegistry{ pluginRegistry := &fakes.FakePluginRegistry{
Store: map[string]*plugins.Plugin{ Store: map[string]*plugins.Plugin{

View File

@ -55,7 +55,7 @@ type FS interface {
fs.FS fs.FS
Base() string Base() string
Files() []string Files() ([]string, error)
} }
type FSRemover interface { type FSRemover interface {

View File

@ -2,6 +2,7 @@ package plugins
import ( import (
"errors" "errors"
"fmt"
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
@ -10,78 +11,152 @@ import (
"github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util"
) )
var _ fs.FS = (*LocalFS)(nil) var (
_ fs.File = &LocalFile{}
_ FS = &LocalFS{}
_ FS = &StaticFS{}
)
// LocalFS is a plugins.FS that allows accessing files on the local file system. // LocalFS is a plugins.FS that allows accessing files on the local file system.
type LocalFS struct { type LocalFS struct {
// m is a map of relative file paths that can be accessed on the local filesystem. // basePath is the basePath that will be prepended to all the files to get their absolute path.
// The path separator must be os-specific.
m map[string]*LocalFile
// basePath is the basePath that will be prepended to all the files (in m map) before accessing them.
basePath string basePath string
} }
// NewLocalFS returns a new LocalFS that can access the specified files in the specified base path. // NewLocalFS returns a new LocalFS that can access any file in the specified base path on the filesystem.
// Both the map keys and basePath should use the os-specific path separator for Open() to work properly. // basePath must use os-specific path separator for Open() to work properly.
func NewLocalFS(m map[string]struct{}, basePath string) LocalFS { func NewLocalFS(basePath string) LocalFS {
pfs := make(map[string]*LocalFile, len(m)) return LocalFS{basePath: basePath}
for k := range m { }
pfs[k] = &LocalFile{
path: k, // fileIsAllowed takes an absolute path to a file and an os.FileInfo for that file, and it checks if access to that
// file is allowed or not. Access to a file is allowed if the file is in the FS's Base() directory, and if it's a
// symbolic link it should not end up outside the plugin's directory.
func (f LocalFS) fileIsAllowed(basePath string, absolutePath string, info os.FileInfo) (bool, error) {
if info.Mode()&os.ModeSymlink == os.ModeSymlink {
symlinkPath, err := filepath.EvalSymlinks(absolutePath)
if err != nil {
return false, err
}
symlink, err := os.Stat(symlinkPath)
if err != nil {
return false, err
}
// verify that symlinked file is within plugin directory
p, err := filepath.Rel(basePath, symlinkPath)
if err != nil {
return false, err
}
if p == ".." || strings.HasPrefix(p, ".."+string(filepath.Separator)) {
return false, fmt.Errorf("file '%s' not inside of plugin directory", p)
}
// skip adding symlinked directories
if symlink.IsDir() {
return false, nil
} }
} }
return LocalFS{ // skip directories
m: pfs, if info.IsDir() {
basePath: basePath, return false, nil
}
// verify that file is within plugin directory
file, err := filepath.Rel(f.Base(), absolutePath)
if err != nil {
return false, err
}
if strings.HasPrefix(file, ".."+string(filepath.Separator)) {
return false, fmt.Errorf("file '%s' not inside of plugin directory", file)
}
return true, nil
}
// walkFunc returns a filepath.WalkFunc that accumulates absolute file paths into acc by walking over f.Base().
// f.fileIsAllowed is used as WalkFunc, see its documentation for more information on which files are collected.
func (f LocalFS) walkFunc(basePath string, acc map[string]struct{}) filepath.WalkFunc {
return func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
ok, err := f.fileIsAllowed(basePath, path, info)
if err != nil {
return err
}
if !ok {
return nil
}
acc[path] = struct{}{}
return nil
} }
} }
// Open opens the specified file on the local filesystem, and returns the corresponding fs.File. // Open opens the specified file on the local filesystem.
// If a nil error is returned, the caller should take care of closing the returned file. // The provided name must be a relative file name that uses os-specific path separators.
// The function returns the corresponding fs.File for accessing the file on the local filesystem.
// If a nil error is returned, the caller should take care of calling Close() the returned fs.File.
// If the file does not exist, ErrFileNotExist is returned.
func (f LocalFS) Open(name string) (fs.File, error) { func (f LocalFS) Open(name string) (fs.File, error) {
cleanPath, err := util.CleanRelativePath(name) cleanPath, err := util.CleanRelativePath(name)
if err != nil { if err != nil {
return nil, err return nil, err
} }
basePath := f.Base()
if kv, exists := f.m[filepath.Join(f.basePath, cleanPath)]; exists { absFn := filepath.Join(basePath, cleanPath)
if kv.f != nil { finfo, err := os.Stat(absFn)
return kv.f, nil
}
file, err := os.Open(kv.path)
if err != nil { if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, ErrFileNotExist return nil, ErrFileNotExist
} }
return nil, ErrPluginFileRead // Make sure access to the file is allowed (symlink check, etc)
} ok, err := f.fileIsAllowed(basePath, absFn, finfo)
return file, nil if err != nil {
return nil, err
} }
if !ok {
return nil, ErrFileNotExist return nil, ErrFileNotExist
}
return &LocalFile{path: absFn}, nil
} }
// Base returns the base path for the LocalFS. // Base returns the base path for the LocalFS.
// The returned string uses os-specific path separator.
func (f LocalFS) Base() string { func (f LocalFS) Base() string {
return f.basePath return f.basePath
} }
// Files returns a slice of all the file paths in the LocalFS relative to the base path. // Files returns a slice of all the relative file paths on the LocalFS.
// The returned strings use the same path separator as the // The returned strings can be passed to Open() to open those files.
func (f LocalFS) Files() []string { // The returned strings use os-specific path separator.
var files []string func (f LocalFS) Files() ([]string, error) {
for p := range f.m { // Accumulate all files into filesMap by calling f.collectFilesFunc, which will write into the accumulator.
r, err := filepath.Rel(f.basePath, p) // Those are absolute because filepath.Walk uses absolute file paths.
if strings.Contains(r, "..") || err != nil { absFilePaths := make(map[string]struct{})
if err := filepath.Walk(f.basePath, f.walkFunc(f.Base(), absFilePaths)); err != nil {
return nil, fmt.Errorf("walk: %w", err)
}
// Convert the accumulator into a slice of relative path strings
relFiles := make([]string, 0, len(absFilePaths))
base := f.Base()
for fn := range absFilePaths {
relPath, err := filepath.Rel(base, fn)
if err != nil {
return nil, err
}
clenRelPath, err := util.CleanRelativePath(relPath)
if strings.Contains(clenRelPath, "..") || err != nil {
continue continue
} }
files = append(files, r) relFiles = append(relFiles, clenRelPath)
} }
return relFiles, nil
return files
} }
// Remove removes a plugin from the local filesystem by deleting all files in the folder.
// It returns ErrUninstallInvalidPluginDir is the plugin does not contain plugin.json nor dist/plugin.json.
func (f LocalFS) Remove() error { func (f LocalFS) Remove() error {
// extra security check to ensure we only remove a directory that looks like a plugin // extra security check to ensure we only remove a directory that looks like a plugin
if _, err := os.Stat(filepath.Join(f.basePath, "plugin.json")); os.IsNotExist(err) { if _, err := os.Stat(filepath.Join(f.basePath, "plugin.json")); os.IsNotExist(err) {
@ -89,11 +164,72 @@ func (f LocalFS) Remove() error {
return ErrUninstallInvalidPluginDir return ErrUninstallInvalidPluginDir
} }
} }
return os.RemoveAll(f.basePath) return os.RemoveAll(f.basePath)
} }
var _ fs.File = (*LocalFile)(nil) // staticFilesMap is a set-like map that contains files that can be accessed from a plugins.FS.
type staticFilesMap map[string]struct{}
// isAllowed returns true if the provided path is allowed.
// path is a string accepted by an FS Open() method.
func (a staticFilesMap) isAllowed(path string) bool {
_, ok := a[path]
return ok
}
// newStaticFilesMap creates a new staticFilesMap from a list of allowed file paths.
func newStaticFilesMap(files ...string) staticFilesMap {
m := staticFilesMap(make(map[string]struct{}, len(files)))
for _, k := range files {
m[k] = struct{}{}
}
return m
}
// StaticFS wraps an FS and allows accessing only the files in the allowList.
// This is a more secure implementation of a FS suitable for production environments.
// The keys of the allow list must be in the same format used by the underlying FS' Open() method.
type StaticFS struct {
FS
// staticFilesMap is a map of allowed paths (accepted by FS.Open())
staticFilesMap staticFilesMap
}
// NewStaticFS returns a new StaticFS that can access the files on an underlying FS,
// but only if they are also specified in a static list, which is constructed when creating the object
// by calling Files() on the underlying FS.
func NewStaticFS(fs FS) (StaticFS, error) {
files, err := fs.Files()
if err != nil {
return StaticFS{}, err
}
return StaticFS{
FS: fs,
staticFilesMap: newStaticFilesMap(files...),
}, nil
}
// Open checks that name is an allowed file and, if so, it returns a fs.File to access it, by calling the
// underlying FS' Open() method.
// If access is denied, the function returns ErrFileNotExist.
func (f StaticFS) Open(name string) (fs.File, error) {
// Ensure access to the file is allowed
if !f.staticFilesMap.isAllowed(name) {
return nil, ErrFileNotExist
}
// Use the wrapped FS to access the file
return f.FS.Open(name)
}
// Files returns a slice of all static file paths relative to the base path.
func (f StaticFS) Files() ([]string, error) {
files := make([]string, 0, len(f.staticFilesMap))
for fn := range f.staticFilesMap {
files = append(files, fn)
}
return files, nil
}
// LocalFile implements a fs.File for accessing the local filesystem. // LocalFile implements a fs.File for accessing the local filesystem.
type LocalFile struct { type LocalFile struct {

View File

@ -1,9 +1,11 @@
package plugins package plugins
import ( import (
"errors"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"testing" "testing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -17,14 +19,7 @@ func TestLocalFS_Remove(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
err = f.Close() err = f.Close()
require.NoError(t, err) require.NoError(t, err)
fs := NewLocalFS(pluginDir)
fs := NewLocalFS(
map[string]struct{}{
"plugin.json": {},
},
pluginDir,
)
err = fs.Remove() err = fs.Remove()
require.NoError(t, err) require.NoError(t, err)
@ -49,12 +44,7 @@ func TestLocalFS_Remove(t *testing.T) {
pluginDir = filepath.Dir(pluginDistDir) pluginDir = filepath.Dir(pluginDistDir)
fs = NewLocalFS( fs = NewLocalFS(pluginDir)
map[string]struct{}{
"dist/plugin.json": {},
},
pluginDir,
)
err = fs.Remove() err = fs.Remove()
require.NoError(t, err) require.NoError(t, err)
@ -78,12 +68,7 @@ func TestLocalFS_Remove(t *testing.T) {
err = f.Close() err = f.Close()
require.NoError(t, err) require.NoError(t, err)
fs = NewLocalFS( fs = NewLocalFS(pluginDir)
map[string]struct{}{
"system32/important.exe": {},
},
pluginDir,
)
err = fs.Remove() err = fs.Remove()
require.ErrorIs(t, err, ErrUninstallInvalidPluginDir) require.ErrorIs(t, err, ErrUninstallInvalidPluginDir)
@ -222,3 +207,73 @@ func newTempFileScenarioForTest(t *testing.T) tempFileScenario {
require.NoError(t, err) require.NoError(t, err)
return s return s
} }
func createDummyTempFile(dir, fn string) (err error) {
f, err := os.Create(filepath.Join(dir, fn)) // nolint: gosec
if err != nil {
return err
}
defer func() {
if closeErr := f.Close(); closeErr != nil && err == nil {
err = closeErr
}
}()
_, err = f.WriteString(fn)
return
}
func TestStaticFS(t *testing.T) {
tmp := t.TempDir()
const allowedFn, deniedFn = "allowed.txt", "denied.txt"
require.NoError(t, createDummyTempFile(tmp, allowedFn))
localFS := NewLocalFS(tmp)
staticFS, err := NewStaticFS(localFS)
require.NoError(t, err)
t.Run("open allowed", func(t *testing.T) {
f, err := staticFS.Open(allowedFn)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, f.Close()) })
b, err := io.ReadAll(f)
require.NoError(t, err)
require.Equal(t, []byte(allowedFn), b)
})
t.Run("open denied", func(t *testing.T) {
// Add file after initialization
require.NoError(t, createDummyTempFile(tmp, deniedFn))
// StaticFS should fail
_, err := staticFS.Open(deniedFn)
require.True(t, errors.Is(err, ErrFileNotExist))
// Underlying FS should succeed
f, err := localFS.Open(deniedFn)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, f.Close()) })
b, err := io.ReadAll(f)
require.NoError(t, err)
require.Equal(t, []byte("denied.txt"), b)
})
t.Run("open not existing", func(t *testing.T) {
_, err := staticFS.Open("unknown.txt")
require.True(t, errors.Is(err, ErrFileNotExist))
})
t.Run("list files", func(t *testing.T) {
t.Run("underlying fs has extra files", func(t *testing.T) {
files, err := localFS.Files()
require.NoError(t, err)
sort.Strings(files)
require.Equal(t, []string{allowedFn, deniedFn}, files)
})
t.Run("staticfs filters underelying fs's files", func(t *testing.T) {
files, err := staticFS.Files()
require.NoError(t, err)
require.Equal(t, []string{allowedFn}, files)
})
})
}

View File

@ -364,8 +364,8 @@ func (f *FakePluginFiles) Base() string {
return f.base return f.base
} }
func (f *FakePluginFiles) Files() []string { func (f *FakePluginFiles) Files() ([]string, error) {
return []string{} return []string{}, nil
} }
func (f *FakePluginFiles) Remove() error { func (f *FakePluginFiles) Remove() error {

View File

@ -12,6 +12,7 @@ import (
"github.com/grafana/grafana/pkg/infra/fs" "github.com/grafana/grafana/pkg/infra/fs"
"github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/plugins/log"
"github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util"
) )
@ -25,14 +26,20 @@ var (
type Local struct { type Local struct {
log log.Logger log log.Logger
production bool
} }
func NewLocalFinder() *Local { func NewLocalFinder(cfg *config.Cfg) *Local {
return &Local{ return &Local{
production: !cfg.DevMode,
log: log.New("local.finder"), log: log.New("local.finder"),
} }
} }
func ProvideLocalFinder(cfg *config.Cfg) *Local {
return NewLocalFinder(cfg)
}
func (l *Local) Find(ctx context.Context, src plugins.PluginSource) ([]*plugins.FoundBundle, error) { func (l *Local) Find(ctx context.Context, src plugins.PluginSource) ([]*plugins.FoundBundle, error) {
if len(src.PluginURIs(ctx)) == 0 { if len(src.PluginURIs(ctx)) == 0 {
return []*plugins.FoundBundle{}, nil return []*plugins.FoundBundle{}, nil
@ -81,15 +88,21 @@ func (l *Local) Find(ctx context.Context, src plugins.PluginSource) ([]*plugins.
var res = make(map[string]*plugins.FoundBundle) var res = make(map[string]*plugins.FoundBundle)
for pluginDir, data := range foundPlugins { for pluginDir, data := range foundPlugins {
files, err := collectFilesWithin(pluginDir) var pluginFs plugins.FS
pluginFs = plugins.NewLocalFS(pluginDir)
if l.production {
// In prod, tighten up security by allowing access only to the files present up to this point.
// Any new file "sneaked in" won't be allowed and will acts as if the file did not exist.
var err error
pluginFs, err = plugins.NewStaticFS(pluginFs)
if err != nil { if err != nil {
return nil, err return nil, err
} }
}
res[pluginDir] = &plugins.FoundBundle{ res[pluginDir] = &plugins.FoundBundle{
Primary: plugins.FoundPlugin{ Primary: plugins.FoundPlugin{
JSONData: data, JSONData: data,
FS: plugins.NewLocalFS(files, pluginDir), FS: pluginFs,
}, },
} }
} }
@ -190,61 +203,6 @@ func (l *Local) getAbsPluginJSONPaths(path string) ([]string, error) {
return pluginJSONPaths, nil return pluginJSONPaths, nil
} }
func collectFilesWithin(dir string) (map[string]struct{}, error) {
files := map[string]struct{}{}
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink == os.ModeSymlink {
symlinkPath, err := filepath.EvalSymlinks(path)
if err != nil {
return err
}
symlink, err := os.Stat(symlinkPath)
if err != nil {
return err
}
// verify that symlinked file is within plugin directory
p, err := filepath.Rel(dir, symlinkPath)
if err != nil {
return err
}
if p == ".." || strings.HasPrefix(p, ".."+string(filepath.Separator)) {
return fmt.Errorf("file '%s' not inside of plugin directory", p)
}
// skip adding symlinked directories
if symlink.IsDir() {
return nil
}
}
// skip directories
if info.IsDir() {
return nil
}
// verify that file is within plugin directory
file, err := filepath.Rel(dir, path)
if err != nil {
return err
}
if strings.HasPrefix(file, ".."+string(filepath.Separator)) {
return fmt.Errorf("file '%s' not inside of plugin directory", file)
}
files[path] = struct{}{}
return nil
})
return files, err
}
func (l *Local) readFile(pluginJSONPath string) (io.ReadCloser, error) { func (l *Local) readFile(pluginJSONPath string) (io.ReadCloser, error) {
l.log.Debug("Loading plugin", "path", pluginJSONPath) l.log.Debug("Loading plugin", "path", pluginJSONPath)

View File

@ -14,7 +14,10 @@ import (
"github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/manager/fakes" "github.com/grafana/grafana/pkg/plugins/manager/fakes"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/pluginsintegration/config"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util"
) )
@ -23,6 +26,11 @@ func TestFinder_Find(t *testing.T) {
if err != nil { if err != nil {
require.NoError(t, err) require.NoError(t, err)
} }
cfg := setting.NewCfg()
pCfg, err := config.ProvideConfig(setting.ProvideProvider(cfg), cfg, featuremgmt.WithFeatures())
require.NoError(t, err)
testCases := []struct { testCases := []struct {
name string name string
pluginDirs []string pluginDirs []string
@ -55,10 +63,7 @@ func TestFinder_Find(t *testing.T) {
Backend: true, Backend: true,
Executable: "test", Executable: "test",
}, },
FS: plugins.NewLocalFS(map[string]struct{}{ FS: mustNewStaticFSForTests(t, filepath.Join(testData, "valid-v2-signature/plugin")),
filepath.Join(testData, "valid-v2-signature/plugin/plugin.json"): {},
filepath.Join(testData, "valid-v2-signature/plugin/MANIFEST.txt"): {},
}, filepath.Join(testData, "valid-v2-signature/plugin")),
}, },
}, },
}, },
@ -87,12 +92,7 @@ func TestFinder_Find(t *testing.T) {
Plugins: []plugins.Dependency{}, Plugins: []plugins.Dependency{},
}, },
}, },
FS: plugins.NewLocalFS(map[string]struct{}{ FS: mustNewStaticFSForTests(t, filepath.Join(testData, "duplicate-plugins/nested")),
filepath.Join(testData, "duplicate-plugins/nested/plugin.json"): {},
filepath.Join(testData, "duplicate-plugins/nested/MANIFEST.txt"): {},
filepath.Join(testData, "duplicate-plugins/nested/nested/plugin.json"): {},
filepath.Join(testData, "duplicate-plugins/nested/nested/MANIFEST.txt"): {},
}, filepath.Join(testData, "duplicate-plugins/nested")),
}, },
Children: []*plugins.FoundPlugin{ Children: []*plugins.FoundPlugin{
{ {
@ -114,10 +114,7 @@ func TestFinder_Find(t *testing.T) {
Plugins: []plugins.Dependency{}, Plugins: []plugins.Dependency{},
}, },
}, },
FS: plugins.NewLocalFS(map[string]struct{}{ FS: mustNewStaticFSForTests(t, filepath.Join(testData, "duplicate-plugins/nested/nested")),
filepath.Join(testData, "duplicate-plugins/nested/nested/plugin.json"): {},
filepath.Join(testData, "duplicate-plugins/nested/nested/MANIFEST.txt"): {},
}, filepath.Join(testData, "duplicate-plugins/nested/nested")),
}, },
}, },
}, },
@ -178,14 +175,7 @@ func TestFinder_Find(t *testing.T) {
{Name: "Nginx Datasource", Type: "datasource", Role: "Viewer"}, {Name: "Nginx Datasource", Type: "datasource", Role: "Viewer"},
}, },
}, },
FS: plugins.NewLocalFS(map[string]struct{}{ FS: mustNewStaticFSForTests(t, filepath.Join(testData, "includes-symlinks")),
filepath.Join(testData, "includes-symlinks/MANIFEST.txt"): {},
filepath.Join(testData, "includes-symlinks/dashboards/connections.json"): {},
filepath.Join(testData, "includes-symlinks/dashboards/extra/memory.json"): {},
filepath.Join(testData, "includes-symlinks/plugin.json"): {},
filepath.Join(testData, "includes-symlinks/symlink_to_txt"): {},
filepath.Join(testData, "includes-symlinks/text.txt"): {},
}, filepath.Join(testData, "includes-symlinks")),
}, },
}, },
}, },
@ -213,12 +203,7 @@ func TestFinder_Find(t *testing.T) {
Plugins: []plugins.Dependency{}, Plugins: []plugins.Dependency{},
}, },
}, },
FS: plugins.NewLocalFS(map[string]struct{}{ FS: mustNewStaticFSForTests(t, filepath.Join(testData, "duplicate-plugins/nested")),
filepath.Join(testData, "duplicate-plugins/nested/plugin.json"): {},
filepath.Join(testData, "duplicate-plugins/nested/MANIFEST.txt"): {},
filepath.Join(testData, "duplicate-plugins/nested/nested/plugin.json"): {},
filepath.Join(testData, "duplicate-plugins/nested/nested/MANIFEST.txt"): {},
}, filepath.Join(testData, "duplicate-plugins/nested")),
}, },
Children: []*plugins.FoundPlugin{ Children: []*plugins.FoundPlugin{
{ {
@ -240,10 +225,7 @@ func TestFinder_Find(t *testing.T) {
Plugins: []plugins.Dependency{}, Plugins: []plugins.Dependency{},
}, },
}, },
FS: plugins.NewLocalFS(map[string]struct{}{ FS: mustNewStaticFSForTests(t, filepath.Join(testData, "duplicate-plugins/nested/nested")),
filepath.Join(testData, "duplicate-plugins/nested/nested/plugin.json"): {},
filepath.Join(testData, "duplicate-plugins/nested/nested/MANIFEST.txt"): {},
}, filepath.Join(testData, "duplicate-plugins/nested/nested")),
}, },
}, },
}, },
@ -267,10 +249,7 @@ func TestFinder_Find(t *testing.T) {
State: plugins.AlphaRelease, State: plugins.AlphaRelease,
Backend: true, Backend: true,
}, },
FS: plugins.NewLocalFS(map[string]struct{}{ FS: mustNewStaticFSForTests(t, filepath.Join(testData, "invalid-v1-signature/plugin")),
filepath.Join(testData, "invalid-v1-signature/plugin/plugin.json"): {},
filepath.Join(testData, "invalid-v1-signature/plugin/MANIFEST.txt"): {},
}, filepath.Join(testData, "invalid-v1-signature/plugin")),
}, },
}, },
}, },
@ -278,7 +257,7 @@ func TestFinder_Find(t *testing.T) {
} }
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
f := NewLocalFinder() f := NewLocalFinder(pCfg)
pluginBundles, err := f.Find(context.Background(), &fakes.FakePluginSource{ pluginBundles, err := f.Find(context.Background(), &fakes.FakePluginSource{
PluginURIsFunc: func(ctx context.Context) []string { PluginURIsFunc: func(ctx context.Context) []string {
return tc.pluginDirs return tc.pluginDirs
@ -294,14 +273,18 @@ func TestFinder_Find(t *testing.T) {
return pluginBundles[i].Primary.JSONData.ID < pluginBundles[j].Primary.JSONData.ID return pluginBundles[i].Primary.JSONData.ID < pluginBundles[j].Primary.JSONData.ID
}) })
if !cmp.Equal(pluginBundles, tc.expectedBundles, localFSComparer) { if !cmp.Equal(pluginBundles, tc.expectedBundles, fsComparer) {
t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(pluginBundles, tc.expectedBundles, localFSComparer)) t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(pluginBundles, tc.expectedBundles, fsComparer))
} }
}) })
} }
} }
func TestFinder_getAbsPluginJSONPaths(t *testing.T) { func TestFinder_getAbsPluginJSONPaths(t *testing.T) {
cfg := setting.NewCfg()
pCfg, err := config.ProvideConfig(setting.ProvideProvider(cfg), cfg, featuremgmt.WithFeatures())
require.NoError(t, err)
t.Run("When scanning a folder that doesn't exists shouldn't return an error", func(t *testing.T) { t.Run("When scanning a folder that doesn't exists shouldn't return an error", func(t *testing.T) {
origWalk := walk origWalk := walk
walk = func(path string, followSymlinks, detectSymlinkInfiniteLoop bool, walkFn util.WalkFunc) error { walk = func(path string, followSymlinks, detectSymlinkInfiniteLoop bool, walkFn util.WalkFunc) error {
@ -311,7 +294,7 @@ func TestFinder_getAbsPluginJSONPaths(t *testing.T) {
walk = origWalk walk = origWalk
}) })
finder := NewLocalFinder() finder := NewLocalFinder(pCfg)
paths, err := finder.getAbsPluginJSONPaths("test") paths, err := finder.getAbsPluginJSONPaths("test")
require.NoError(t, err) require.NoError(t, err)
require.Empty(t, paths) require.Empty(t, paths)
@ -326,7 +309,7 @@ func TestFinder_getAbsPluginJSONPaths(t *testing.T) {
walk = origWalk walk = origWalk
}) })
finder := NewLocalFinder() finder := NewLocalFinder(pCfg)
paths, err := finder.getAbsPluginJSONPaths("test") paths, err := finder.getAbsPluginJSONPaths("test")
require.NoError(t, err) require.NoError(t, err)
require.Empty(t, paths) require.Empty(t, paths)
@ -341,7 +324,7 @@ func TestFinder_getAbsPluginJSONPaths(t *testing.T) {
walk = origWalk walk = origWalk
}) })
finder := NewLocalFinder() finder := NewLocalFinder(pCfg)
paths, err := finder.getAbsPluginJSONPaths("test") paths, err := finder.getAbsPluginJSONPaths("test")
require.Error(t, err) require.Error(t, err)
require.Empty(t, paths) require.Empty(t, paths)
@ -469,9 +452,15 @@ func TestFinder_readPluginJSON(t *testing.T) {
} }
} }
var localFSComparer = cmp.Comparer(func(fs1 plugins.LocalFS, fs2 plugins.LocalFS) bool { var fsComparer = cmp.Comparer(func(fs1 plugins.FS, fs2 plugins.FS) bool {
fs1Files := fs1.Files() fs1Files, err := fs1.Files()
fs2Files := fs2.Files() if err != nil {
panic(err)
}
fs2Files, err := fs2.Files()
if err != nil {
panic(err)
}
sort.SliceStable(fs1Files, func(i, j int) bool { sort.SliceStable(fs1Files, func(i, j int) bool {
return fs1Files[i] < fs1Files[j] return fs1Files[i] < fs1Files[j]
@ -483,3 +472,9 @@ var localFSComparer = cmp.Comparer(func(fs1 plugins.LocalFS, fs2 plugins.LocalFS
return cmp.Equal(fs1Files, fs2Files) && fs1.Base() == fs2.Base() return cmp.Equal(fs1Files, fs2Files) && fs1.Base() == fs2.Base()
}) })
func mustNewStaticFSForTests(t *testing.T, dir string) plugins.FS {
sfs, err := plugins.NewStaticFS(plugins.NewLocalFS(dir))
require.NoError(t, err)
return sfs
}

View File

@ -3,7 +3,6 @@ package loader
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"sort" "sort"
"testing" "testing"
@ -28,13 +27,18 @@ import (
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
) )
var compareOpts = []cmp.Option{cmpopts.IgnoreFields(plugins.Plugin{}, "client", "log"), localFSComparer} var compareOpts = []cmp.Option{cmpopts.IgnoreFields(plugins.Plugin{}, "client", "log"), fsComparer}
var localFSComparer = cmp.Comparer(func(fs1 plugins.LocalFS, fs2 plugins.LocalFS) bool { var fsComparer = cmp.Comparer(func(fs1 plugins.FS, fs2 plugins.FS) bool {
fs1Files := fs1.Files() fs1Files, err := fs1.Files()
fs2Files := fs2.Files() if err != nil {
panic(err)
}
fs2Files, err := fs2.Files()
if err != nil {
panic(err)
}
finder.NewLocalFinder()
sort.SliceStable(fs1Files, func(i, j int) bool { sort.SliceStable(fs1Files, func(i, j int) bool {
return fs1Files[i] < fs1Files[j] return fs1Files[i] < fs1Files[j]
}) })
@ -108,9 +112,8 @@ func TestLoader_Load(t *testing.T) {
}, },
Module: "app/plugins/datasource/cloudwatch/module", Module: "app/plugins/datasource/cloudwatch/module",
BaseURL: "public/app/plugins/datasource/cloudwatch", BaseURL: "public/app/plugins/datasource/cloudwatch",
FS: plugins.NewLocalFS(
filesInDir(t, filepath.Join(corePluginDir, "app/plugins/datasource/cloudwatch")), FS: mustNewStaticFSForTests(t, filepath.Join(corePluginDir, "app/plugins/datasource/cloudwatch")),
filepath.Join(corePluginDir, "app/plugins/datasource/cloudwatch")),
Signature: plugins.SignatureInternal, Signature: plugins.SignatureInternal,
Class: plugins.Core, Class: plugins.Core,
}, },
@ -149,10 +152,7 @@ func TestLoader_Load(t *testing.T) {
}, },
Module: "plugins/test-datasource/module", Module: "plugins/test-datasource/module",
BaseURL: "public/plugins/test-datasource", BaseURL: "public/plugins/test-datasource",
FS: plugins.NewLocalFS( FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/valid-v2-signature/plugin/")),
filesInDir(t, filepath.Join(parentDir, "testdata/valid-v2-signature/plugin/")),
filepath.Join(parentDir, "testdata/valid-v2-signature/plugin/"),
),
Signature: "valid", Signature: "valid",
SignatureType: plugins.GrafanaSignature, SignatureType: plugins.GrafanaSignature,
SignatureOrg: "Grafana Labs", SignatureOrg: "Grafana Labs",
@ -229,17 +229,7 @@ func TestLoader_Load(t *testing.T) {
Class: plugins.External, Class: plugins.External,
Module: "plugins/test-app/module", Module: "plugins/test-app/module",
BaseURL: "public/plugins/test-app", BaseURL: "public/plugins/test-app",
FS: plugins.NewLocalFS( FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/includes-symlinks")),
map[string]struct{}{
filepath.Join(parentDir, "testdata/includes-symlinks", "/MANIFEST.txt"): {},
filepath.Join(parentDir, "testdata/includes-symlinks", "dashboards/connections.json"): {},
filepath.Join(parentDir, "testdata/includes-symlinks", "dashboards/extra/memory.json"): {},
filepath.Join(parentDir, "testdata/includes-symlinks", "plugin.json"): {},
filepath.Join(parentDir, "testdata/includes-symlinks", "symlink_to_txt"): {},
filepath.Join(parentDir, "testdata/includes-symlinks", "text.txt"): {},
},
filepath.Join(parentDir, "testdata/includes-symlinks"),
),
Signature: "valid", Signature: "valid",
SignatureType: plugins.GrafanaSignature, SignatureType: plugins.GrafanaSignature,
SignatureOrg: "Grafana Labs", SignatureOrg: "Grafana Labs",
@ -279,10 +269,7 @@ func TestLoader_Load(t *testing.T) {
Class: plugins.External, Class: plugins.External,
Module: "plugins/test-datasource/module", Module: "plugins/test-datasource/module",
BaseURL: "public/plugins/test-datasource", BaseURL: "public/plugins/test-datasource",
FS: plugins.NewLocalFS( FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/unsigned-datasource/plugin")),
filesInDir(t, filepath.Join(parentDir, "testdata/unsigned-datasource/plugin")),
filepath.Join(parentDir, "testdata/unsigned-datasource/plugin"),
),
Signature: "unsigned", Signature: "unsigned",
}, },
}, },
@ -333,10 +320,7 @@ func TestLoader_Load(t *testing.T) {
Class: plugins.External, Class: plugins.External,
Module: "plugins/test-datasource/module", Module: "plugins/test-datasource/module",
BaseURL: "public/plugins/test-datasource", BaseURL: "public/plugins/test-datasource",
FS: plugins.NewLocalFS( FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/unsigned-datasource/plugin")),
filesInDir(t, filepath.Join(parentDir, "testdata/unsigned-datasource/plugin")),
filepath.Join(parentDir, "testdata/unsigned-datasource/plugin"),
),
Signature: plugins.SignatureUnsigned, Signature: plugins.SignatureUnsigned,
}, },
}, },
@ -440,10 +424,7 @@ func TestLoader_Load(t *testing.T) {
Backend: false, Backend: false,
}, },
DefaultNavURL: "/plugins/test-app/page/root-page-react", DefaultNavURL: "/plugins/test-app/page/root-page-react",
FS: plugins.NewLocalFS(map[string]struct{}{ FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/test-app-with-includes")),
filepath.Join(parentDir, "testdata/test-app-with-includes", "dashboards/memory.json"): {},
filepath.Join(parentDir, "testdata/test-app-with-includes", "plugin.json"): {},
}, filepath.Join(parentDir, "testdata/test-app-with-includes")),
Class: plugins.External, Class: plugins.External,
Signature: plugins.SignatureUnsigned, Signature: plugins.SignatureUnsigned,
Module: "plugins/test-app/module", Module: "plugins/test-app/module",
@ -532,9 +513,7 @@ func TestLoader_Load_CustomSource(t *testing.T) {
Plugins: []plugins.Dependency{}, Plugins: []plugins.Dependency{},
}, },
}, },
FS: plugins.NewLocalFS(map[string]struct{}{ FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/cdn/plugin")),
filepath.Join(parentDir, "testdata/cdn/plugin", "plugin.json"): {},
}, filepath.Join(parentDir, "testdata/cdn/plugin")),
Class: plugins.Bundled, Class: plugins.Bundled,
Signature: plugins.SignatureValid, Signature: plugins.SignatureValid,
BaseURL: "plugin-cdn/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel", BaseURL: "plugin-cdn/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel",
@ -673,10 +652,7 @@ func TestLoader_Load_MultiplePlugins(t *testing.T) {
Class: plugins.External, Class: plugins.External,
Module: "plugins/test-datasource/module", Module: "plugins/test-datasource/module",
BaseURL: "public/plugins/test-datasource", BaseURL: "public/plugins/test-datasource",
FS: plugins.NewLocalFS(map[string]struct{}{ FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/valid-v2-pvt-signature/plugin")),
filepath.Join(parentDir, "testdata/valid-v2-pvt-signature/plugin/plugin.json"): {},
filepath.Join(parentDir, "testdata/valid-v2-pvt-signature/plugin/MANIFEST.txt"): {},
}, filepath.Join(parentDir, "testdata/valid-v2-pvt-signature/plugin")),
Signature: "valid", Signature: "valid",
SignatureType: plugins.PrivateSignature, SignatureType: plugins.PrivateSignature,
SignatureOrg: "Will Browne", SignatureOrg: "Will Browne",
@ -795,10 +771,7 @@ func TestLoader_Load_RBACReady(t *testing.T) {
}, },
Backend: false, Backend: false,
}, },
FS: plugins.NewLocalFS(map[string]struct{}{ FS: mustNewStaticFSForTests(t, pluginDir),
filepath.Join(pluginDir, "plugin.json"): {},
filepath.Join(pluginDir, "MANIFEST.txt"): {},
}, pluginDir),
Class: plugins.External, Class: plugins.External,
Signature: plugins.SignatureValid, Signature: plugins.SignatureValid,
SignatureType: plugins.PrivateSignature, SignatureType: plugins.PrivateSignature,
@ -885,10 +858,7 @@ func TestLoader_Load_Signature_RootURL(t *testing.T) {
Backend: true, Backend: true,
Executable: "test", Executable: "test",
}, },
FS: plugins.NewLocalFS(map[string]struct{}{ FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "/testdata/valid-v2-pvt-signature-root-url-uri/plugin")),
filepath.Join(filepath.Join(parentDir, "/testdata/valid-v2-pvt-signature-root-url-uri/plugin"), "plugin.json"): {},
filepath.Join(filepath.Join(parentDir, "/testdata/valid-v2-pvt-signature-root-url-uri/plugin"), "MANIFEST.txt"): {},
}, filepath.Join(parentDir, "/testdata/valid-v2-pvt-signature-root-url-uri/plugin")),
Class: plugins.External, Class: plugins.External,
Signature: plugins.SignatureValid, Signature: plugins.SignatureValid,
SignatureType: plugins.PrivateSignature, SignatureType: plugins.PrivateSignature,
@ -972,7 +942,7 @@ func TestLoader_Load_DuplicatePlugins(t *testing.T) {
}, },
Backend: false, Backend: false,
}, },
FS: plugins.NewLocalFS(filesInDir(t, pluginDir), pluginDir), FS: mustNewStaticFSForTests(t, pluginDir),
Class: plugins.External, Class: plugins.External,
Signature: plugins.SignatureValid, Signature: plugins.SignatureValid,
SignatureType: plugins.GrafanaSignature, SignatureType: plugins.GrafanaSignature,
@ -1062,7 +1032,7 @@ func TestLoader_Load_SkipUninitializedPlugins(t *testing.T) {
}, },
Backend: false, Backend: false,
}, },
FS: plugins.NewLocalFS(filesInDir(t, pluginDir1), pluginDir1), FS: mustNewStaticFSForTests(t, pluginDir1),
Class: plugins.External, Class: plugins.External,
Signature: plugins.SignatureValid, Signature: plugins.SignatureValid,
SignatureType: plugins.GrafanaSignature, SignatureType: plugins.GrafanaSignature,
@ -1139,8 +1109,7 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
}, },
Module: "plugins/test-datasource/module", Module: "plugins/test-datasource/module",
BaseURL: "public/plugins/test-datasource", BaseURL: "public/plugins/test-datasource",
FS: plugins.NewLocalFS(filesInDir(t, filepath.Join(rootDir, "testdata/nested-plugins/parent")), FS: mustNewStaticFSForTests(t, filepath.Join(rootDir, "testdata/nested-plugins/parent")),
filepath.Join(rootDir, "testdata/nested-plugins/parent")),
Signature: plugins.SignatureValid, Signature: plugins.SignatureValid,
SignatureType: plugins.GrafanaSignature, SignatureType: plugins.GrafanaSignature,
SignatureOrg: "Grafana Labs", SignatureOrg: "Grafana Labs",
@ -1172,8 +1141,7 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
}, },
Module: "plugins/test-panel/module", Module: "plugins/test-panel/module",
BaseURL: "public/plugins/test-panel", BaseURL: "public/plugins/test-panel",
FS: plugins.NewLocalFS(filesInDir(t, filepath.Join(rootDir, "testdata/nested-plugins/parent/nested")), FS: mustNewStaticFSForTests(t, filepath.Join(rootDir, "testdata/nested-plugins/parent/nested")),
filepath.Join(rootDir, "testdata/nested-plugins/parent/nested")),
Signature: plugins.SignatureValid, Signature: plugins.SignatureValid,
SignatureType: plugins.GrafanaSignature, SignatureType: plugins.GrafanaSignature,
SignatureOrg: "Grafana Labs", SignatureOrg: "Grafana Labs",
@ -1312,8 +1280,7 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
}, },
Module: "plugins/myorgid-simple-app/module", Module: "plugins/myorgid-simple-app/module",
BaseURL: "public/plugins/myorgid-simple-app", BaseURL: "public/plugins/myorgid-simple-app",
FS: plugins.NewLocalFS(filesInDir(t, filepath.Join(rootDir, "testdata/app-with-child/dist")), FS: mustNewStaticFSForTests(t, filepath.Join(rootDir, "testdata/app-with-child/dist")),
filepath.Join(rootDir, "testdata/app-with-child/dist")),
DefaultNavURL: "/plugins/myorgid-simple-app/page/root-page-react", DefaultNavURL: "/plugins/myorgid-simple-app/page/root-page-react",
Signature: plugins.SignatureValid, Signature: plugins.SignatureValid,
SignatureType: plugins.GrafanaSignature, SignatureType: plugins.GrafanaSignature,
@ -1351,8 +1318,7 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
}, },
Module: "plugins/myorgid-simple-app/child/module", Module: "plugins/myorgid-simple-app/child/module",
BaseURL: "public/plugins/myorgid-simple-app", BaseURL: "public/plugins/myorgid-simple-app",
FS: plugins.NewLocalFS(filesInDir(t, filepath.Join(rootDir, "testdata/app-with-child/dist/child")), FS: mustNewStaticFSForTests(t, filepath.Join(rootDir, "testdata/app-with-child/dist/child")),
filepath.Join(rootDir, "testdata/app-with-child/dist/child")),
IncludedInAppID: parent.ID, IncludedInAppID: parent.ID,
Signature: plugins.SignatureValid, Signature: plugins.SignatureValid,
SignatureType: plugins.GrafanaSignature, SignatureType: plugins.GrafanaSignature,
@ -1421,7 +1387,7 @@ func Test_setPathsBasedOnApp(t *testing.T) {
func newLoader(cfg *config.Cfg, cbs ...func(loader *Loader)) *Loader { func newLoader(cfg *config.Cfg, cbs ...func(loader *Loader)) *Loader {
l := New(cfg, &fakes.FakeLicensingService{}, signature.NewUnsignedAuthorizer(cfg), fakes.NewFakePluginRegistry(), l := New(cfg, &fakes.FakeLicensingService{}, signature.NewUnsignedAuthorizer(cfg), fakes.NewFakePluginRegistry(),
fakes.NewFakeBackendProcessProvider(), fakes.NewFakeProcessManager(), fakes.NewFakeRoleRegistry(), fakes.NewFakeBackendProcessProvider(), fakes.NewFakeProcessManager(), fakes.NewFakeRoleRegistry(),
assetpath.ProvideService(pluginscdn.ProvideService(cfg)), finder.NewLocalFinder(), assetpath.ProvideService(pluginscdn.ProvideService(cfg)), finder.NewLocalFinder(cfg),
signature.ProvideService(cfg, keystore.ProvideService(kvstore.NewFakeKVStore()))) signature.ProvideService(cfg, keystore.ProvideService(kvstore.NewFakeKVStore())))
for _, cb := range cbs { for _, cb := range cbs {
@ -1453,39 +1419,8 @@ func verifyState(t *testing.T, ps []*plugins.Plugin, reg *fakes.FakePluginRegist
} }
} }
func filesInDir(t *testing.T, dir string) map[string]struct{} { func mustNewStaticFSForTests(t *testing.T, dir string) plugins.FS {
files, err := collectFilesWithin(dir) sfs, err := plugins.NewStaticFS(plugins.NewLocalFS(dir))
if err != nil { require.NoError(t, err)
t.Logf("Could not collect plugin file info. Err: %v", err) return sfs
return map[string]struct{}{}
}
return files
}
func collectFilesWithin(dir string) (map[string]struct{}, error) {
files := map[string]struct{}{}
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// skip directories
if info.IsDir() {
return nil
}
// verify that file is within plugin directory
//file, err := filepath.Rel(dir, path)
//if err != nil {
// return err
//}
//if strings.HasPrefix(file, ".."+string(filepath.Separator)) {
// return fmt.Errorf("file '%s' not inside of plugin directory", file)
//}
files[path] = struct{}{}
return nil
})
return files, err
} }

View File

@ -118,7 +118,7 @@ func TestIntegrationPluginManager(t *testing.T) {
reg := registry.ProvideService() reg := registry.ProvideService()
lic := plicensing.ProvideLicensing(cfg, &licensing.OSSLicensingService{Cfg: cfg}) lic := plicensing.ProvideLicensing(cfg, &licensing.OSSLicensingService{Cfg: cfg})
l := loader.ProvideService(pCfg, lic, signature.NewUnsignedAuthorizer(pCfg), l := loader.ProvideService(pCfg, lic, signature.NewUnsignedAuthorizer(pCfg),
reg, provider.ProvideService(coreRegistry), finder.NewLocalFinder(), fakes.NewFakeRoleRegistry(), reg, provider.ProvideService(coreRegistry), finder.NewLocalFinder(pCfg), fakes.NewFakeRoleRegistry(),
assetpath.ProvideService(pluginscdn.ProvideService(pCfg)), signature.ProvideService(pCfg, keystore.ProvideService(kvstore.NewFakeKVStore()))) assetpath.ProvideService(pluginscdn.ProvideService(pCfg)), signature.ProvideService(pCfg, keystore.ProvideService(kvstore.NewFakeKVStore())))
srcs := sources.ProvideService(cfg, pCfg) srcs := sources.ProvideService(cfg, pCfg)
ps, err := store.ProvideService(reg, srcs, l) ps, err := store.ProvideService(reg, srcs, l)

View File

@ -17,6 +17,7 @@ import (
"github.com/ProtonMail/go-crypto/openpgp/clearsign" "github.com/ProtonMail/go-crypto/openpgp/clearsign"
"github.com/gobwas/glob" "github.com/gobwas/glob"
"github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/plugins/log"
@ -29,6 +30,9 @@ var (
// toSlash is filepath.ToSlash, but can be overwritten in tests path separators cross-platform // toSlash is filepath.ToSlash, but can be overwritten in tests path separators cross-platform
toSlash = filepath.ToSlash toSlash = filepath.ToSlash
// fromSlash is filepath.FromSlash, but can be overwritten in tests path separators cross-platform
fromSlash = filepath.FromSlash
) )
// PluginManifest holds details for the file manifest // PluginManifest holds details for the file manifest
@ -100,8 +104,11 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu
if defaultSignature, exists := src.DefaultSignature(ctx); exists { if defaultSignature, exists := src.DefaultSignature(ctx); exists {
return defaultSignature, nil return defaultSignature, nil
} }
fsFiles, err := plugin.FS.Files()
if len(plugin.FS.Files()) == 0 { if err != nil {
return plugins.Signature{}, fmt.Errorf("files: %w", err)
}
if len(fsFiles) == 0 {
s.mlog.Warn("No plugin file information in directory", "pluginID", plugin.JSONData.ID) s.mlog.Warn("No plugin file information in directory", "pluginID", plugin.JSONData.ID)
return plugins.Signature{ return plugins.Signature{
Status: plugins.SignatureInvalid, Status: plugins.SignatureInvalid,
@ -190,7 +197,7 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu
// Track files missing from the manifest // Track files missing from the manifest
var unsignedFiles []string var unsignedFiles []string
for _, f := range plugin.FS.Files() { for _, f := range fsFiles {
// Ensure slashes are used, because MANIFEST.txt always uses slashes regardless of the filesystem // Ensure slashes are used, because MANIFEST.txt always uses slashes regardless of the filesystem
f = toSlash(f) f = toSlash(f)
@ -223,6 +230,8 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu
} }
func verifyHash(mlog log.Logger, plugin plugins.FoundPlugin, path, hash string) error { func verifyHash(mlog log.Logger, plugin plugins.FoundPlugin, path, hash string) error {
path = fromSlash(path)
// nolint:gosec // nolint:gosec
// We can ignore the gosec G304 warning on this one because `path` is based // We can ignore the gosec G304 warning on this one because `path` is based
// on the path provided in a manifest file for a plugin and not user input. // on the path provided in a manifest file for a plugin and not user input.

View File

@ -2,6 +2,7 @@ package signature
import ( import (
"context" "context"
"io/fs"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
@ -172,10 +173,7 @@ func TestCalculate(t *testing.T) {
Version: "1.0.0", Version: "1.0.0",
}, },
}, },
FS: plugins.NewLocalFS(map[string]struct{}{ FS: mustNewStaticFSForTests(t, basePath),
filepath.Join(basePath, "MANIFEST.txt"): {},
filepath.Join(basePath, "plugin.json"): {},
}, basePath),
}) })
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, tc.expectedSignature, sig) require.Equal(t, tc.expectedSignature, sig)
@ -204,11 +202,7 @@ func TestCalculate(t *testing.T) {
Version: "1.0.0", Version: "1.0.0",
}, },
}, },
FS: plugins.NewLocalFS(map[string]struct{}{ FS: mustNewStaticFSForTests(t, basePath),
filepath.Join(basePath, "MANIFEST.txt"): {},
filepath.Join(basePath, "plugin.json"): {},
filepath.Join(basePath, "chrome-win/debug.log"): {},
}, basePath),
}) })
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, plugins.Signature{ require.Equal(t, plugins.Signature{
@ -219,28 +213,51 @@ func TestCalculate(t *testing.T) {
}) })
t.Run("Signature verification should work with any path separator", func(t *testing.T) { t.Run("Signature verification should work with any path separator", func(t *testing.T) {
var toSlashUnix = newToSlash('/') const basePath = "../testdata/app-with-child/dist"
var toSlashWindows = newToSlash('\\')
for _, tc := range []struct { platformWindows := fsPlatform{separator: '\\'}
platformUnix := fsPlatform{separator: '/'}
type testCase struct {
name string name string
sep string platform fsPlatform
toSlash func(string) string fsFactory func() (plugins.FS, error)
}
var testCases []testCase
for _, fsFactory := range []struct {
name string
f func() (plugins.FS, error)
}{ }{
{"unix", "/", toSlashUnix}, {"local fs", func() (plugins.FS, error) {
{"windows", "\\", toSlashWindows}, return plugins.NewLocalFS(basePath), nil
}},
{"static fs", func() (plugins.FS, error) {
return plugins.NewStaticFS(plugins.NewLocalFS(basePath))
}},
} { } {
testCases = append(testCases, []testCase{
{"unix " + fsFactory.name, platformUnix, fsFactory.f},
{"windows " + fsFactory.name, platformWindows, fsFactory.f},
}...)
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
// Replace toSlash for cross-platform testing // Replace toSlash for cross-platform testing
oldToSlash := toSlash oldToSlash := toSlash
oldFromSlash := fromSlash
t.Cleanup(func() { t.Cleanup(func() {
toSlash = oldToSlash toSlash = oldToSlash
fromSlash = oldFromSlash
}) })
toSlash = tc.toSlash toSlash = tc.platform.toSlashFunc()
fromSlash = tc.platform.fromSlashFunc()
basePath := "../testdata/app-with-child/dist"
s := ProvideService(&config.Cfg{}, keystore.ProvideService(kvstore.NewFakeKVStore())) s := ProvideService(&config.Cfg{}, keystore.ProvideService(kvstore.NewFakeKVStore()))
pfs, err := tc.fsFactory()
require.NoError(t, err)
pfs, err = newPathSeparatorOverrideFS(string(tc.platform.separator), pfs)
require.NoError(t, err)
sig, err := s.Calculate(context.Background(), &fakes.FakePluginSource{ sig, err := s.Calculate(context.Background(), &fakes.FakePluginSource{
PluginClassFunc: func(ctx context.Context) plugins.Class { PluginClassFunc: func(ctx context.Context) plugins.Class {
return plugins.External return plugins.External
@ -253,11 +270,7 @@ func TestCalculate(t *testing.T) {
Version: "%VERSION%", Version: "%VERSION%",
}, },
}, },
FS: newPathSeparatorOverrideFS(tc.sep, map[string]struct{}{ FS: pfs,
filepath.Join(basePath, "MANIFEST.txt"): {},
filepath.Join(basePath, "plugin.json"): {},
filepath.Join(basePath, "child/plugin.json"): {},
}, basePath),
}) })
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, plugins.Signature{ require.Equal(t, plugins.Signature{
@ -270,20 +283,35 @@ func TestCalculate(t *testing.T) {
}) })
} }
// newToSlash returns a new function that acts as filepath.ToSlash but for the specified os-separator. type fsPlatform struct {
separator rune
}
// toSlashFunc returns a new function that acts as filepath.ToSlash but for the specified os-separator.
// This can be used to test filepath.ToSlash-dependant code cross-platform. // This can be used to test filepath.ToSlash-dependant code cross-platform.
func newToSlash(sep rune) func(string) string { func (p fsPlatform) toSlashFunc() func(string) string {
return func(path string) string { return func(path string) string {
if sep == '/' { if p.separator == '/' {
return path return path
} }
return strings.ReplaceAll(path, string(sep), "/") return strings.ReplaceAll(path, string(p.separator), "/")
} }
} }
func TestNewToSlash(t *testing.T) { // fromSlashFunc returns a new function that acts as filepath.FromSlash but for the specified os-separator.
// This can be used to test filepath.FromSlash-dependant code cross-platform.
func (p fsPlatform) fromSlashFunc() func(string) string {
return func(path string) string {
if p.separator == '/' {
return path
}
return strings.ReplaceAll(path, "/", string(p.separator))
}
}
func TestFsPlatform(t *testing.T) {
t.Run("unix", func(t *testing.T) { t.Run("unix", func(t *testing.T) {
toSlashUnix := newToSlash('/') toSlashUnix := fsPlatform{'/'}.toSlashFunc()
require.Equal(t, "folder", toSlashUnix("folder")) require.Equal(t, "folder", toSlashUnix("folder"))
require.Equal(t, "/folder", toSlashUnix("/folder")) require.Equal(t, "/folder", toSlashUnix("/folder"))
require.Equal(t, "/folder/file", toSlashUnix("/folder/file")) require.Equal(t, "/folder/file", toSlashUnix("/folder/file"))
@ -291,39 +319,46 @@ func TestNewToSlash(t *testing.T) {
}) })
t.Run("windows", func(t *testing.T) { t.Run("windows", func(t *testing.T) {
toSlashWindows := newToSlash('\\') toSlashWindows := fsPlatform{'\\'}.toSlashFunc()
require.Equal(t, "folder", toSlashWindows("folder")) require.Equal(t, "folder", toSlashWindows("folder"))
require.Equal(t, "C:/folder", toSlashWindows("C:\\folder")) require.Equal(t, "C:/folder", toSlashWindows("C:\\folder"))
require.Equal(t, "folder/file.exe", toSlashWindows("folder\\file.exe")) require.Equal(t, "folder/file.exe", toSlashWindows("folder\\file.exe"))
}) })
} }
// fsPathSeparatorFiles embeds plugins.LocalFS and overrides the Files() behaviour so all the returned elements // fsPathSeparatorFiles embeds a plugins.FS and overrides the Files() behaviour so all the returned elements
// have the specified path separator. This can be used to test Files() behaviour cross-platform. // have the specified path separator. This can be used to test Files() behaviour cross-platform.
type fsPathSeparatorFiles struct { type fsPathSeparatorFiles struct {
plugins.LocalFS plugins.FS
separator string separator string
} }
// newPathSeparatorOverrideFS returns a new fsPathSeparatorFiles. Sep is the separator that will be used ONLY for // newPathSeparatorOverrideFS returns a new fsPathSeparatorFiles. Sep is the separator that will be used ONLY for
// the elements returned by Files(). Files and basePath MUST use the os-specific path separator (filepath.Separator) // the elements returned by Files().
// if Open() is required to work for the test case. func newPathSeparatorOverrideFS(sep string, ufs plugins.FS) (fsPathSeparatorFiles, error) {
func newPathSeparatorOverrideFS(sep string, files map[string]struct{}, basePath string) fsPathSeparatorFiles {
return fsPathSeparatorFiles{ return fsPathSeparatorFiles{
LocalFS: plugins.NewLocalFS(files, basePath), FS: ufs,
separator: sep, separator: sep,
} }, nil
} }
// Files returns LocalFS.Files(), but all path separators (filepath.Separator) are replaced with f.separator. // Files returns LocalFS.Files(), but all path separators for the current platform (filepath.Separator)
func (f fsPathSeparatorFiles) Files() []string { // are replaced with f.separator.
files := f.LocalFS.Files() func (f fsPathSeparatorFiles) Files() ([]string, error) {
files, err := f.FS.Files()
if err != nil {
return nil, err
}
const osSepStr = string(filepath.Separator) const osSepStr = string(filepath.Separator)
for i := 0; i < len(files); i++ { for i := 0; i < len(files); i++ {
files[i] = strings.ReplaceAll(files[i], osSepStr, f.separator) files[i] = strings.ReplaceAll(files[i], osSepStr, f.separator)
} }
return files return files, nil
}
func (f fsPathSeparatorFiles) Open(name string) (fs.File, error) {
return f.FS.Open(strings.ReplaceAll(name, f.separator, string(filepath.Separator)))
} }
func TestFSPathSeparatorFiles(t *testing.T) { func TestFSPathSeparatorFiles(t *testing.T) {
@ -335,17 +370,18 @@ func TestFSPathSeparatorFiles(t *testing.T) {
{"windows", "\\"}, {"windows", "\\"},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
fs := newPathSeparatorOverrideFS("/", map[string]struct{}{ pfs, err := newPathSeparatorOverrideFS(
"a": {}, "/", plugins.NewInMemoryFS(
strings.Join([]string{"a", "b", "c"}, tc.sep): {}, map[string][]byte{"a": nil, strings.Join([]string{"a", "b", "c"}, tc.sep): nil},
}, ".") ),
files := fs.Files() )
filesMap := make(map[string]struct{}, len(files)) require.NoError(t, err)
// Re-convert to map as the key order is not stable files, err := pfs.Files()
for _, f := range files { require.NoError(t, err)
filesMap[f] = struct{}{} exp := []string{"a", strings.Join([]string{"a", "b", "c"}, tc.sep)}
} sort.Strings(files)
require.Equal(t, filesMap, map[string]struct{}{"a": {}, strings.Join([]string{"a", "b", "c"}, tc.sep): {}}) sort.Strings(exp)
require.Equal(t, exp, files)
}) })
} }
} }
@ -715,3 +751,9 @@ func createV2Manifest(t *testing.T, cbs ...func(*PluginManifest)) *PluginManifes
return m return m
} }
func mustNewStaticFSForTests(t *testing.T, dir string) plugins.FS {
sfs, err := plugins.NewStaticFS(plugins.NewLocalFS(dir))
require.NoError(t, err)
return sfs
}

82
pkg/plugins/test_utils.go Normal file
View File

@ -0,0 +1,82 @@
package plugins
import (
"bytes"
"io/fs"
"os"
"time"
)
var (
_ FS = &inMemoryFS{}
_ fs.File = &inMemoryFile{}
_ fs.FileInfo = &inMemoryFileInfo{}
)
// inMemoryFS is an FS that stores files in-memory.
type inMemoryFS struct {
files map[string][]byte
}
// NewInMemoryFS returns a new FS with the specified files and content.
// The provided value is a map from file name (keys) to file content (values).
func NewInMemoryFS(files map[string][]byte) FS {
return &inMemoryFS{files: files}
}
func (f inMemoryFS) Base() string {
return ""
}
func (f inMemoryFS) Files() ([]string, error) {
fps := make([]string, 0, len(f.files))
for fn := range f.files {
fps = append(fps, fn)
}
return fps, nil
}
func (f inMemoryFS) Open(fn string) (fs.File, error) {
if _, ok := f.files[fn]; !ok {
return nil, ErrFileNotExist
}
return &inMemoryFile{path: fn, reader: bytes.NewReader(f.files[fn])}, nil
}
// NewFakeFS returns a new FS that always returns ErrFileNotExist when trying to Open() and empty Files().
func NewFakeFS() FS {
return NewInMemoryFS(nil)
}
// inMemoryFile is a fs.File whose content is stored in memory.
type inMemoryFile struct {
path string
reader *bytes.Reader
}
func (f *inMemoryFile) Stat() (fs.FileInfo, error) {
return inMemoryFileInfo{
name: f.path,
size: f.reader.Size(),
}, nil
}
func (f *inMemoryFile) Read(b []byte) (int, error) {
return f.reader.Read(b)
}
func (f *inMemoryFile) Close() error {
return nil
}
type inMemoryFileInfo struct {
name string
size int64
}
func (f inMemoryFileInfo) Name() string { return f.name }
func (f inMemoryFileInfo) Size() int64 { return f.size }
func (f inMemoryFileInfo) Mode() os.FileMode { return 0444 } // Read for all
func (f inMemoryFileInfo) ModTime() time.Time { return time.Time{} }
func (f inMemoryFileInfo) IsDir() bool { return false }
func (f inMemoryFileInfo) Sys() interface{} { return nil }

View File

@ -81,7 +81,7 @@ var WireExtensionSet = wire.NewSet(
signature.ProvideOSSAuthorizer, signature.ProvideOSSAuthorizer,
wire.Bind(new(plugins.PluginLoaderAuthorizer), new(*signature.UnsignedPluginAuthorizer)), wire.Bind(new(plugins.PluginLoaderAuthorizer), new(*signature.UnsignedPluginAuthorizer)),
wire.Bind(new(finder.Finder), new(*finder.Local)), wire.Bind(new(finder.Finder), new(*finder.Local)),
finder.NewLocalFinder, finder.ProvideLocalFinder,
) )
func ProvideClientDecorator( func ProvideClientDecorator(