package storage import ( "archive/zip" "bytes" "context" "errors" "fmt" "io" "os" "path/filepath" "regexp" "strings" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/log" ) var _ ZipExtractor = (*FS)(nil) var reGitBuild = regexp.MustCompile("^[a-zA-Z0-9_.-]*/") type FS struct { pluginsDir string log log.PrettyLogger } func FileSystem(logger log.PrettyLogger, pluginsDir string) *FS { return &FS{ pluginsDir: pluginsDir, log: logger, } } var SimpleDirNameGeneratorFunc = func(pluginID string) string { return pluginID } func (fs *FS) Extract(ctx context.Context, pluginID string, dirNameFunc DirNameGeneratorFunc, pluginArchive *zip.ReadCloser) ( *ExtractedPluginArchive, error) { pluginDir, err := fs.extractFiles(ctx, pluginArchive, pluginID, dirNameFunc) if err != nil { return nil, fmt.Errorf("%v: %w", "failed to extract plugin archive", err) } pluginJSON, err := readPluginJSON(pluginDir) if err != nil { return nil, fmt.Errorf("%v: %w", "failed to convert to plugin DTO", err) } fs.log.Successf("Downloaded and extracted %s v%s zip successfully to %s", pluginJSON.ID, pluginJSON.Info.Version, pluginDir) deps := make([]*Dependency, 0, len(pluginJSON.Dependencies.Plugins)) for _, plugin := range pluginJSON.Dependencies.Plugins { deps = append(deps, &Dependency{ ID: plugin.ID, }) } return &ExtractedPluginArchive{ ID: pluginJSON.ID, Version: pluginJSON.Info.Version, Dependencies: deps, Path: pluginDir, }, nil } func (fs *FS) extractFiles(_ context.Context, pluginArchive *zip.ReadCloser, pluginID string, dirNameFunc DirNameGeneratorFunc) (string, error) { pluginDirName := dirNameFunc(pluginID) installDir := filepath.Join(fs.pluginsDir, pluginDirName) if _, err := os.Stat(installDir); !os.IsNotExist(err) { fs.log.Debugf("Removing existing installation of plugin %s", installDir) err = os.RemoveAll(installDir) if err != nil { return "", err } } defer func() { if err := pluginArchive.Close(); err != nil { fs.log.Warn("Failed to close zip file", "error", err) } }() for _, zf := range pluginArchive.File { // We can ignore gosec G305 here since we check for the ZipSlip vulnerability below // nolint:gosec fullPath := filepath.Join(fs.pluginsDir, zf.Name) // Check for ZipSlip. More Info: http://bit.ly/2MsjAWE if filepath.IsAbs(zf.Name) || !strings.HasPrefix(fullPath, filepath.Clean(fs.pluginsDir)+string(os.PathSeparator)) || strings.HasPrefix(zf.Name, ".."+string(os.PathSeparator)) { return "", fmt.Errorf( "archive member %q tries to write outside of plugin directory: %q, this can be a security risk", zf.Name, fs.pluginsDir) } dstPath := filepath.Clean(filepath.Join(fs.pluginsDir, removeGitBuildFromName(zf.Name, pluginDirName))) // lgtm[go/zipslip] if zf.FileInfo().IsDir() { // We can ignore gosec G304 here since it makes sense to give all users read access // nolint:gosec if err := os.MkdirAll(dstPath, 0755); err != nil { if os.IsPermission(err) { return "", ErrPermissionDenied{Path: dstPath} } return "", err } continue } // Create needed directories to extract file // We can ignore gosec G304 here since it makes sense to give all users read access // nolint:gosec if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil { return "", fmt.Errorf("%v: %w", "failed to create directory to extract plugin files", err) } if isSymlink(zf) { if err := extractSymlink(installDir, zf, dstPath); err != nil { fs.log.Warn("Failed to extract symlink", "error", err) continue } continue } if err := extractFile(zf, dstPath); err != nil { return "", fmt.Errorf("%v: %w", "failed to extract file", err) } } return installDir, nil } func isSymlink(file *zip.File) bool { return file.Mode()&os.ModeSymlink == os.ModeSymlink } func extractSymlink(basePath string, file *zip.File, filePath string) error { // symlink target is the contents of the file src, err := file.Open() if err != nil { return fmt.Errorf("%v: %w", "failed to extract file", err) } buf := new(bytes.Buffer) if _, err = io.Copy(buf, src); err != nil { return fmt.Errorf("%v: %w", "failed to copy symlink contents", err) } symlinkPath := strings.TrimSpace(buf.String()) if !isSymlinkRelativeTo(basePath, symlinkPath, filePath) { return fmt.Errorf("symlink %q pointing outside plugin directory is not allowed", filePath) } if err = os.Symlink(symlinkPath, filePath); err != nil { return fmt.Errorf("failed to make symbolic link for %v: %w", filePath, err) } return nil } // isSymlinkRelativeTo checks whether symlinkDestPath is relative to basePath. // symlinkOrigPath is the path to file holding the symbolic link. func isSymlinkRelativeTo(basePath string, symlinkDestPath string, symlinkOrigPath string) bool { if filepath.IsAbs(symlinkDestPath) { return false } fileDir := filepath.Dir(symlinkOrigPath) cleanPath := filepath.Clean(filepath.Join(fileDir, symlinkDestPath)) p, err := filepath.Rel(basePath, cleanPath) if err != nil { return false } if strings.HasPrefix(filepath.Clean(p), "..") { return false } return true } func extractFile(file *zip.File, filePath string) (err error) { fileMode := file.Mode() // This is entry point for backend plugins so we want to make them executable if strings.HasSuffix(filePath, "_linux_amd64") || strings.HasSuffix(filePath, "_linux_arm") || strings.HasSuffix(filePath, "_linux_arm64") || strings.HasSuffix(filePath, "_darwin_amd64") || strings.HasSuffix(filePath, "_darwin_arm64") || strings.HasSuffix(filePath, "_windows_amd64.exe") { fileMode = os.FileMode(0755) } // We can ignore the gosec G304 warning on this one, since the variable part of the file path stems // from command line flag "pluginsDir", and the only possible damage would be writing to the wrong directory. // If the user shouldn't be writing to this directory, they shouldn't have the permission in the file system. // nolint:gosec dst, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileMode) if err != nil { if os.IsPermission(err) { return fmt.Errorf("could not create %q, permission denied, make sure you have write access to plugin dir", filePath) } unwrappedError := errors.Unwrap(err) if unwrappedError != nil && strings.EqualFold(unwrappedError.Error(), "text file busy") { return fmt.Errorf("file %q is in use - please stop Grafana, install the plugin and restart Grafana", filePath) } return fmt.Errorf("%v: %w", "failed to open file", err) } defer func() { err = dst.Close() }() src, err := file.Open() if err != nil { return fmt.Errorf("%v: %w", "failed to extract file", err) } defer func() { err = src.Close() }() _, err = io.Copy(dst, src) return err } func removeGitBuildFromName(filename, pluginID string) string { return reGitBuild.ReplaceAllString(filename, pluginID+"/") } func readPluginJSON(pluginDir string) (plugins.JSONData, error) { pluginPath := filepath.Join(pluginDir, "plugin.json") // It's safe to ignore gosec warning G304 since the file path suffix is hardcoded // nolint:gosec data, err := os.ReadFile(pluginPath) if err != nil { pluginPath = filepath.Join(pluginDir, "dist", "plugin.json") // It's safe to ignore gosec warning G304 since the file path suffix is hardcoded // nolint:gosec data, err = os.ReadFile(pluginPath) if err != nil { return plugins.JSONData{}, fmt.Errorf("could not find plugin.json or dist/plugin.json in %s", pluginDir) } } pJSON, err := plugins.ReadPluginJSON(bytes.NewReader(data)) if err != nil { return plugins.JSONData{}, err } return pJSON, nil }