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
+180 -44
View File
@@ -2,6 +2,7 @@ package plugins
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
@@ -10,78 +11,152 @@ import (
"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.
type LocalFS struct {
// m is a map of relative file paths that can be accessed on the local filesystem.
// 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 is the basePath that will be prepended to all the files to get their absolute path.
basePath string
}
// NewLocalFS returns a new LocalFS that can access the specified files in the specified base path.
// Both the map keys and basePath should use the os-specific path separator for Open() to work properly.
func NewLocalFS(m map[string]struct{}, basePath string) LocalFS {
pfs := make(map[string]*LocalFile, len(m))
for k := range m {
pfs[k] = &LocalFile{
path: k,
// NewLocalFS returns a new LocalFS that can access any file in the specified base path on the filesystem.
// basePath must use os-specific path separator for Open() to work properly.
func NewLocalFS(basePath string) LocalFS {
return LocalFS{basePath: basePath}
}
// 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{
m: pfs,
basePath: basePath,
// skip directories
if info.IsDir() {
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.
// If a nil error is returned, the caller should take care of closing the returned file.
// Open opens the specified file on the local filesystem.
// 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) {
cleanPath, err := util.CleanRelativePath(name)
if err != nil {
return nil, err
}
if kv, exists := f.m[filepath.Join(f.basePath, cleanPath)]; exists {
if kv.f != nil {
return kv.f, nil
}
file, err := os.Open(kv.path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, ErrFileNotExist
}
return nil, ErrPluginFileRead
}
return file, nil
basePath := f.Base()
absFn := filepath.Join(basePath, cleanPath)
finfo, err := os.Stat(absFn)
if err != nil {
return nil, ErrFileNotExist
}
return nil, ErrFileNotExist
// Make sure access to the file is allowed (symlink check, etc)
ok, err := f.fileIsAllowed(basePath, absFn, finfo)
if err != nil {
return nil, err
}
if !ok {
return nil, ErrFileNotExist
}
return &LocalFile{path: absFn}, nil
}
// Base returns the base path for the LocalFS.
// The returned string uses os-specific path separator.
func (f LocalFS) Base() string {
return f.basePath
}
// Files returns a slice of all the file paths in the LocalFS relative to the base path.
// The returned strings use the same path separator as the
func (f LocalFS) Files() []string {
var files []string
for p := range f.m {
r, err := filepath.Rel(f.basePath, p)
if strings.Contains(r, "..") || err != nil {
// Files returns a slice of all the relative file paths on the LocalFS.
// The returned strings can be passed to Open() to open those files.
// The returned strings use os-specific path separator.
func (f LocalFS) Files() ([]string, error) {
// Accumulate all files into filesMap by calling f.collectFilesFunc, which will write into the accumulator.
// Those are absolute because filepath.Walk uses absolute file paths.
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
}
files = append(files, r)
relFiles = append(relFiles, clenRelPath)
}
return files
return relFiles, nil
}
// 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 {
// 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) {
@@ -89,11 +164,72 @@ func (f LocalFS) Remove() error {
return ErrUninstallInvalidPluginDir
}
}
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.
type LocalFile struct {