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:
+180
-44
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user