diff --git a/pkg/cmd/grafana-cli/commands/install_command.go b/pkg/cmd/grafana-cli/commands/install_command.go index a2bf450ec9b..fe12cdf380d 100644 --- a/pkg/cmd/grafana-cli/commands/install_command.go +++ b/pkg/cmd/grafana-cli/commands/install_command.go @@ -1,26 +1,17 @@ package commands import ( - "archive/zip" - "bytes" "context" "errors" "fmt" - "io" - "io/ioutil" "os" - "path/filepath" - "regexp" "runtime" "strings" - "github.com/fatih/color" "github.com/grafana/grafana/pkg/cmd/grafana-cli/models" "github.com/grafana/grafana/pkg/cmd/grafana-cli/services" "github.com/grafana/grafana/pkg/cmd/grafana-cli/utils" "github.com/grafana/grafana/pkg/plugins/manager/installer" - - "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" ) func validateInput(c utils.CommandLine, pluginFolder string) error { @@ -57,102 +48,16 @@ func (cmd Command) installCommand(c utils.CommandLine) error { pluginID := c.Args().First() version := c.Args().Get(1) - skipTLSVerify := c.Bool("insecure") - - i := installer.New(skipTLSVerify, services.GrafanaVersion, services.Logger) - return i.Install(context.Background(), pluginID, version, c.PluginDirectory(), c.PluginURL(), c.PluginRepoURL()) + return InstallPlugin(pluginID, version, c) } // InstallPlugin downloads the plugin code as a zip file from the Grafana.com API // and then extracts the zip into the plugins directory. -func InstallPlugin(pluginName, version string, c utils.CommandLine, client utils.ApiClient) error { - pluginFolder := c.PluginDirectory() - downloadURL := c.PluginURL() - isInternal := false +func InstallPlugin(pluginID, version string, c utils.CommandLine) error { + skipTLSVerify := c.Bool("insecure") - var checksum string - if downloadURL == "" { - if strings.HasPrefix(pluginName, "grafana-") { - // At this point the plugin download is going through grafana.com API and thus the name is validated. - // Checking for grafana prefix is how it is done there so no 3rd party plugin should have that prefix. - // You can supply custom plugin name and then set custom download url to 3rd party plugin but then that - // is up to the user to know what she is doing. - isInternal = true - } - plugin, err := client.GetPlugin(pluginName, c.PluginRepoURL()) - if err != nil { - return err - } - - v, err := SelectVersion(&plugin, version) - if err != nil { - return err - } - - if version == "" { - version = v.Version - } - downloadURL = fmt.Sprintf("%s/%s/versions/%s/download", - c.String("repo"), - pluginName, - version, - ) - - // Plugins which are downloaded just as sourcecode zipball from github do not have checksum - if v.Arch != nil { - archMeta, exists := v.Arch[osAndArchString()] - if !exists { - archMeta = v.Arch["any"] - } - checksum = archMeta.SHA256 - } - } - - logger.Infof("installing %v @ %v\n", pluginName, version) - logger.Infof("from: %v\n", downloadURL) - logger.Infof("into: %v\n", pluginFolder) - logger.Info("\n") - - // Create temp file for downloading zip file - tmpFile, err := ioutil.TempFile("", "*.zip") - if err != nil { - return fmt.Errorf("%v: %w", "failed to create temporary file", err) - } - defer func() { - if err := os.Remove(tmpFile.Name()); err != nil { - logger.Warn("Failed to remove temporary file", "file", tmpFile.Name(), "err", err) - } - }() - - err = client.DownloadFile(pluginName, tmpFile, downloadURL, checksum) - if err != nil { - if err := tmpFile.Close(); err != nil { - logger.Warn("Failed to close file", "err", err) - } - return fmt.Errorf("%v: %w", "failed to download plugin archive", err) - } - err = tmpFile.Close() - if err != nil { - return fmt.Errorf("%v: %w", "failed to close tmp file", err) - } - - err = extractFiles(tmpFile.Name(), pluginName, pluginFolder, isInternal) - if err != nil { - return fmt.Errorf("%v: %w", "failed to extract plugin archive", err) - } - - logger.Infof("%s Installed %s successfully \n", color.GreenString("✔"), pluginName) - - res, _ := services.ReadPlugin(pluginFolder, pluginName) - for _, v := range res.Dependencies.Plugins { - if err := InstallPlugin(v.ID, "", c, client); err != nil { - return fmt.Errorf("failed to install plugin '%s': %w", v.ID, err) - } - - logger.Infof("Installed dependency: %v ✔\n", v.ID) - } - - return err + i := installer.New(skipTLSVerify, services.GrafanaVersion, services.Logger) + return i.Install(context.Background(), pluginID, version, c.PluginDirectory(), c.PluginURL(), c.PluginRepoURL()) } func osAndArchString() string { @@ -182,177 +87,3 @@ func latestSupportedVersion(plugin *models.Plugin) *models.Version { } return nil } - -// SelectVersion returns latest version if none is specified or the specified version. If the version string is not -// matched to existing version it errors out. It also errors out if version that is matched is not available for current -// os and platform. It expects plugin.Versions to be sorted so the newest version is first. -func SelectVersion(plugin *models.Plugin, version string) (*models.Version, error) { - var ver models.Version - - latestForArch := latestSupportedVersion(plugin) - if latestForArch == nil { - return nil, fmt.Errorf("plugin is not supported on your architecture and OS") - } - - if version == "" { - return latestForArch, nil - } - for _, v := range plugin.Versions { - if v.Version == version { - ver = v - break - } - } - - if len(ver.Version) == 0 { - return nil, fmt.Errorf("could not find the version you're looking for") - } - - if !supportsCurrentArch(&ver) { - return nil, fmt.Errorf( - "the version you want is not supported on your architecture and OS, latest suitable version is %s", - latestForArch.Version) - } - - return &ver, nil -} - -var reGitBuild = regexp.MustCompile("^[a-zA-Z0-9_.-]*/") - -func removeGitBuildFromName(pluginName, filename string) string { - return reGitBuild.ReplaceAllString(filename, pluginName+"/") -} - -const permissionsDeniedMessage = "could not create %q, permission denied, make sure you have write access to plugin dir" - -func extractFiles(archiveFile string, pluginName string, dstDir string, allowSymlinks bool) error { - var err error - dstDir, err = filepath.Abs(dstDir) - if err != nil { - return err - } - logger.Debugf("Extracting archive %q to %q...\n", archiveFile, dstDir) - - existingInstallDir := filepath.Join(dstDir, pluginName) - if _, err := os.Stat(existingInstallDir); !os.IsNotExist(err) { - err = os.RemoveAll(existingInstallDir) - if err != nil { - return err - } - - logger.Infof("Removed existing installation of %s\n\n", pluginName) - } - - r, err := zip.OpenReader(archiveFile) - if err != nil { - return err - } - for _, zf := range r.File { - if filepath.IsAbs(zf.Name) || strings.HasPrefix(zf.Name, ".."+string(filepath.Separator)) { - return fmt.Errorf( - "archive member %q tries to write outside of plugin directory: %q, this can be a security risk", - zf.Name, dstDir) - } - - dstPath := filepath.Clean(filepath.Join(dstDir, removeGitBuildFromName(pluginName, zf.Name))) - - 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 fmt.Errorf(permissionsDeniedMessage, 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 !allowSymlinks { - logger.Warnf("%v: plugin archive contains a symlink, which is not allowed. Skipping \n", zf.Name) - continue - } - if err := extractSymlink(zf, dstPath); err != nil { - logger.Errorf("Failed to extract symlink: %v \n", err) - continue - } - continue - } - - if err := extractFile(zf, dstPath); err != nil { - return fmt.Errorf("%v: %w", "failed to extract file", err) - } - } - - return nil -} - -func isSymlink(file *zip.File) bool { - return file.Mode()&os.ModeSymlink == os.ModeSymlink -} - -func extractSymlink(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) - } - if err := os.Symlink(strings.TrimSpace(buf.String()), filePath); err != nil { - return fmt.Errorf("failed to make symbolic link for %v: %w", filePath, err) - } - return nil -} - -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, "_darwin_amd64") { - 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(permissionsDeniedMessage, 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 -} diff --git a/pkg/cmd/grafana-cli/commands/install_command_test.go b/pkg/cmd/grafana-cli/commands/install_command_test.go deleted file mode 100644 index 0311e876e10..00000000000 --- a/pkg/cmd/grafana-cli/commands/install_command_test.go +++ /dev/null @@ -1,268 +0,0 @@ -package commands - -import ( - "fmt" - "io" - "os" - "path/filepath" - "runtime" - "testing" - - "github.com/grafana/grafana/pkg/cmd/grafana-cli/commands/commandstest" - "github.com/grafana/grafana/pkg/cmd/grafana-cli/models" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestRemoveGitBuildFromName(t *testing.T) { - pluginName := "datasource-kairosdb" - - // The root directory should get renamed to the plugin name - paths := map[string]string{ - "datasource-plugin-kairosdb-cc4a3965ef5d3eb1ae0ee4f93e9e78ec7db69e64/": "datasource-kairosdb/", - "datasource-plugin-kairosdb-cc4a3965ef5d3eb1ae0ee4f93e9e78ec7db69e64/README.md": "datasource-kairosdb/README.md", - "datasource-plugin-kairosdb-cc4a3965ef5d3eb1ae0ee4f93e9e78ec7db69e64/partials/": "datasource-kairosdb/partials/", - "datasource-plugin-kairosdb-cc4a3965ef5d3eb1ae0ee4f93e9e78ec7db69e64/partials/config.html": "datasource-kairosdb/partials/config.html", - } - for pth, exp := range paths { - name := removeGitBuildFromName(pluginName, pth) - assert.Equal(t, exp, name) - } -} - -func TestExtractFiles(t *testing.T) { - t.Run("Should preserve file permissions for plugin backend binaries for linux and darwin", func(t *testing.T) { - skipWindows(t) - pluginsDir := setupFakePluginsDir(t) - - archive := filepath.Join("testdata", "grafana-simple-json-datasource-ec18fa4da8096a952608a7e4c7782b4260b41bcf.zip") - err := extractFiles(archive, "grafana-simple-json-datasource", pluginsDir, false) - require.NoError(t, err) - - // File in zip has permissions 755 - fileInfo, err := os.Stat(filepath.Join(pluginsDir, "grafana-simple-json-datasource", - "simple-plugin_darwin_amd64")) - require.NoError(t, err) - assert.Equal(t, "-rwxr-xr-x", fileInfo.Mode().String()) - - // File in zip has permission 755 - fileInfo, err = os.Stat(pluginsDir + "/grafana-simple-json-datasource/simple-plugin_linux_amd64") - require.NoError(t, err) - assert.Equal(t, "-rwxr-xr-x", fileInfo.Mode().String()) - - // File in zip has permission 644 - fileInfo, err = os.Stat(pluginsDir + "/grafana-simple-json-datasource/simple-plugin_windows_amd64.exe") - require.NoError(t, err) - assert.Equal(t, "-rw-r--r--", fileInfo.Mode().String()) - - // File in zip has permission 755 - fileInfo, err = os.Stat(pluginsDir + "/grafana-simple-json-datasource/non-plugin-binary") - require.NoError(t, err) - assert.Equal(t, "-rwxr-xr-x", fileInfo.Mode().String()) - }) - - t.Run("Should ignore symlinks if not allowed", func(t *testing.T) { - pluginsDir := setupFakePluginsDir(t) - - err := extractFiles("testdata/plugin-with-symlink.zip", "plugin-with-symlink", pluginsDir, false) - require.NoError(t, err) - - _, err = os.Stat(pluginsDir + "/plugin-with-symlink/text.txt") - require.NoError(t, err) - _, err = os.Stat(pluginsDir + "/plugin-with-symlink/symlink_to_txt") - assert.Error(t, err) - }) - - t.Run("Should extract symlinks if allowed", func(t *testing.T) { - skipWindows(t) - pluginsDir := setupFakePluginsDir(t) - - err := extractFiles("testdata/plugin-with-symlink.zip", "plugin-with-symlink", pluginsDir, true) - require.NoError(t, err) - - _, err = os.Stat(pluginsDir + "/plugin-with-symlink/symlink_to_txt") - require.NoError(t, err) - }) - - t.Run("Should detect if archive members point outside of the destination directory", func(t *testing.T) { - pluginsDir := setupFakePluginsDir(t) - - err := extractFiles("testdata/plugin-with-parent-member.zip", "plugin-with-parent-member", - pluginsDir, true) - require.EqualError(t, err, fmt.Sprintf( - `archive member "../member.txt" tries to write outside of plugin directory: %q, this can be a security risk`, - pluginsDir, - )) - }) - - t.Run("Should detect if archive members are absolute", func(t *testing.T) { - pluginsDir := setupFakePluginsDir(t) - - err := extractFiles("testdata/plugin-with-absolute-member.zip", "plugin-with-absolute-member", - pluginsDir, true) - require.EqualError(t, err, fmt.Sprintf( - `archive member "/member.txt" tries to write outside of plugin directory: %q, this can be a security risk`, - pluginsDir, - )) - }) -} - -func TestInstallPluginCommand(t *testing.T) { - pluginsDir := setupFakePluginsDir(t) - c, err := commandstest.NewCliContext(map[string]string{"pluginsDir": pluginsDir}) - require.NoError(t, err) - - client := &commandstest.FakeGrafanaComClient{ - GetPluginFunc: func(pluginId, repoUrl string) (models.Plugin, error) { - require.Equal(t, "test-plugin-panel", pluginId) - plugin := models.Plugin{ - ID: "test-plugin-panel", - Category: "", - Versions: []models.Version{ - { - Commit: "commit", - URL: "url", - Version: "1.0.0", - Arch: map[string]models.ArchMeta{ - fmt.Sprintf("%s-%s", runtime.GOOS, runtime.GOARCH): { - SHA256: "test", - }, - }, - }, - }, - } - return plugin, nil - }, - DownloadFileFunc: func(pluginName string, tmpFile *os.File, url string, checksum string) (err error) { - require.Equal(t, "test-plugin-panel", pluginName) - require.Equal(t, "/test-plugin-panel/versions/1.0.0/download", url) - require.Equal(t, "test", checksum) - f, err := os.Open("testdata/grafana-simple-json-datasource-ec18fa4da8096a952608a7e4c7782b4260b41bcf.zip") - require.NoError(t, err) - _, err = io.Copy(tmpFile, f) - require.NoError(t, err) - return nil - }, - } - - err = InstallPlugin("test-plugin-panel", "", c, client) - assert.NoError(t, err) -} - -func TestSelectVersion(t *testing.T) { - t.Run("Should return error when requested version does not exist", func(t *testing.T) { - _, err := SelectVersion( - makePluginWithVersions(versionArg{Version: "version"}), - "1.1.1", - ) - assert.Error(t, err) - }) - - t.Run("Should return error when no version supports current arch", func(t *testing.T) { - _, err := SelectVersion( - makePluginWithVersions(versionArg{Version: "version", Arch: []string{"non-existent"}}), - "", - ) - assert.Error(t, err) - }) - - t.Run("Should return error when requested version does not support current arch", func(t *testing.T) { - _, err := SelectVersion( - makePluginWithVersions( - versionArg{Version: "2.0.0"}, - versionArg{Version: "1.1.1", Arch: []string{"non-existent"}}, - ), - "1.1.1", - ) - assert.Error(t, err) - }) - - t.Run("Should return latest available for arch when no version specified", func(t *testing.T) { - ver, err := SelectVersion( - makePluginWithVersions( - versionArg{Version: "2.0.0", Arch: []string{"non-existent"}}, - versionArg{Version: "1.0.0"}, - ), - "", - ) - require.NoError(t, err) - assert.Equal(t, "1.0.0", ver.Version) - }) - - t.Run("Should return latest version when no version specified", func(t *testing.T) { - ver, err := SelectVersion( - makePluginWithVersions(versionArg{Version: "2.0.0"}, versionArg{Version: "1.0.0"}), - "", - ) - require.NoError(t, err) - assert.Equal(t, "2.0.0", ver.Version) - }) - - t.Run("Should return requested version", func(t *testing.T) { - ver, err := SelectVersion( - makePluginWithVersions( - versionArg{Version: "2.0.0"}, - versionArg{Version: "1.0.0"}, - ), - "1.0.0", - ) - require.NoError(t, err) - assert.Equal(t, "1.0.0", ver.Version) - }) -} - -func setupFakePluginsDir(t *testing.T) string { - dirname := "testdata/fake-plugins-dir" - err := os.RemoveAll(dirname) - require.NoError(t, err) - - err = os.MkdirAll(dirname, 0750) - require.NoError(t, err) - t.Cleanup(func() { - err := os.RemoveAll(dirname) - assert.NoError(t, err) - }) - - dirname, err = filepath.Abs(dirname) - require.NoError(t, err) - - return dirname -} - -func skipWindows(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("Skipping test on Windows") - } -} - -type versionArg struct { - Version string - Arch []string -} - -func makePluginWithVersions(versions ...versionArg) *models.Plugin { - plugin := &models.Plugin{ - ID: "", - Category: "", - Versions: []models.Version{}, - } - - for _, version := range versions { - ver := models.Version{ - Version: version.Version, - Commit: fmt.Sprintf("commit_%s", version.Version), - URL: fmt.Sprintf("url_%s", version.Version), - } - if version.Arch != nil { - ver.Arch = map[string]models.ArchMeta{} - for _, arch := range version.Arch { - ver.Arch[arch] = models.ArchMeta{ - SHA256: fmt.Sprintf("sha256_%s", arch), - } - } - } - plugin.Versions = append(plugin.Versions, ver) - } - - return plugin -} diff --git a/pkg/cmd/grafana-cli/commands/upgrade_all_command.go b/pkg/cmd/grafana-cli/commands/upgrade_all_command.go index b0030f2a4c7..d669e60d0dc 100644 --- a/pkg/cmd/grafana-cli/commands/upgrade_all_command.go +++ b/pkg/cmd/grafana-cli/commands/upgrade_all_command.go @@ -54,7 +54,7 @@ func (cmd Command) upgradeAllCommand(c utils.CommandLine) error { return err } - err = InstallPlugin(p.ID, "", c, cmd.Client) + err = InstallPlugin(p.ID, "", c) if err != nil { return err } diff --git a/pkg/cmd/grafana-cli/commands/upgrade_command.go b/pkg/cmd/grafana-cli/commands/upgrade_command.go index bca728fba7f..4d7ce1a2f0f 100644 --- a/pkg/cmd/grafana-cli/commands/upgrade_command.go +++ b/pkg/cmd/grafana-cli/commands/upgrade_command.go @@ -29,7 +29,7 @@ func (cmd Command) upgradeCommand(c utils.CommandLine) error { return fmt.Errorf("failed to remove plugin '%s': %w", pluginName, err) } - return InstallPlugin(pluginName, "", c, cmd.Client) + return InstallPlugin(pluginName, "", c) } logger.Infof("%s %s is up to date \n", color.GreenString("✔"), pluginName) diff --git a/pkg/plugins/manager/installer/installer.go b/pkg/plugins/manager/installer/installer.go index 69322880371..63a503cb0fa 100644 --- a/pkg/plugins/manager/installer/installer.go +++ b/pkg/plugins/manager/installer/installer.go @@ -91,17 +91,8 @@ func New(skipTLSVerify bool, grafanaVersion string, logger Logger) Service { // Install downloads the plugin code as a zip file from specified URL // and then extracts the zip into the provided plugins directory. func (i *Installer) Install(ctx context.Context, pluginID, version, pluginsDir, pluginZipURL, pluginRepoURL string) error { - isInternal := false - var checksum string if pluginZipURL == "" { - if strings.HasPrefix(pluginID, "grafana-") { - // At this point the plugin download is going through grafana.com API and thus the name is validated. - // Checking for grafana prefix is how it is done there so no 3rd party plugin should have that prefix. - // You can supply custom plugin name and then set custom download url to 3rd party plugin but then that - // is up to the user to know what she is doing. - isInternal = true - } plugin, err := i.getPluginMetadataFromPluginRepo(pluginID, pluginRepoURL) if err != nil { return err @@ -156,7 +147,7 @@ func (i *Installer) Install(ctx context.Context, pluginID, version, pluginsDir, return fmt.Errorf("%v: %w", "failed to close tmp file", err) } - err = i.extractFiles(tmpFile.Name(), pluginID, pluginsDir, isInternal) + err = i.extractFiles(tmpFile.Name(), pluginID, pluginsDir) if err != nil { return fmt.Errorf("%v: %w", "failed to extract plugin archive", err) } @@ -508,7 +499,7 @@ func latestSupportedVersion(plugin *Plugin) *Version { return nil } -func (i *Installer) extractFiles(archiveFile string, pluginID string, dest string, allowSymlinks bool) error { +func (i *Installer) extractFiles(archiveFile string, pluginID string, dest string) error { var err error dest, err = filepath.Abs(dest) if err != nil { @@ -574,11 +565,7 @@ func (i *Installer) extractFiles(archiveFile string, pluginID string, dest strin } if isSymlink(zf) { - if !allowSymlinks { - i.log.Warnf("%v: plugin archive contains a symlink, which is not allowed. Skipping", zf.Name) - continue - } - if err := extractSymlink(zf, dstPath); err != nil { + if err := extractSymlink(existingInstallDir, zf, dstPath); err != nil { i.log.Warn("failed to extract symlink", "err", err) continue } @@ -597,7 +584,7 @@ func isSymlink(file *zip.File) bool { return file.Mode()&os.ModeSymlink == os.ModeSymlink } -func extractSymlink(file *zip.File, filePath string) error { +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 { @@ -607,12 +594,39 @@ func extractSymlink(file *zip.File, filePath string) error { if _, err := io.Copy(buf, src); err != nil { return fmt.Errorf("%v: %w", "failed to copy symlink contents", err) } - if err := os.Symlink(strings.TrimSpace(buf.String()), filePath); err != nil { + + 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 + } else { + 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(p, ".."+string(filepath.Separator)) { + 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 diff --git a/pkg/plugins/manager/installer/installer_test.go b/pkg/plugins/manager/installer/installer_test.go new file mode 100644 index 00000000000..9454594b47c --- /dev/null +++ b/pkg/plugins/manager/installer/installer_test.go @@ -0,0 +1,358 @@ +package installer + +import ( + "context" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestInstall(t *testing.T) { + testDir := "./testdata/tmpInstallPluginDir" + err := os.Mkdir(testDir, os.ModePerm) + require.NoError(t, err) + + t.Cleanup(func() { + err = os.RemoveAll(testDir) + require.NoError(t, err) + }) + + pluginID := "test-app" + + i := &Installer{log: &fakeLogger{}} + err = i.Install(context.Background(), pluginID, "", testDir, "./testdata/plugin-with-symlinks.zip", "") + require.NoError(t, err) + + // verify extracted contents + files, err := ioutil.ReadDir(filepath.Join(testDir, pluginID)) + require.NoError(t, err) + require.Len(t, files, 6) + require.Equal(t, files[0].Name(), "MANIFEST.txt") + require.Equal(t, files[1].Name(), "dashboards") + require.Equal(t, files[2].Name(), "extra") + require.Equal(t, os.ModeSymlink, files[2].Mode()&os.ModeSymlink) + require.Equal(t, files[3].Name(), "plugin.json") + require.Equal(t, files[4].Name(), "symlink_to_txt") + require.Equal(t, os.ModeSymlink, files[4].Mode()&os.ModeSymlink) + require.Equal(t, files[5].Name(), "text.txt") +} + +func TestUninstall(t *testing.T) { + i := &Installer{log: &fakeLogger{}} + + pluginDir := t.TempDir() + pluginJSON := filepath.Join(pluginDir, "plugin.json") + _, err := os.Create(pluginJSON) + require.NoError(t, err) + + err = i.Uninstall(context.Background(), pluginDir) + require.NoError(t, err) + + _, err = os.Stat(pluginDir) + require.True(t, os.IsNotExist(err)) + + t.Run("Uninstall will search in nested dir folder for plugin.json", func(t *testing.T) { + pluginDistDir := filepath.Join(t.TempDir(), "dist") + err = os.Mkdir(pluginDistDir, os.ModePerm) + require.NoError(t, err) + pluginJSON = filepath.Join(pluginDistDir, "plugin.json") + _, err = os.Create(pluginJSON) + require.NoError(t, err) + + pluginDir = filepath.Dir(pluginDistDir) + + err = i.Uninstall(context.Background(), pluginDir) + require.NoError(t, err) + + _, err = os.Stat(pluginDir) + require.True(t, os.IsNotExist(err)) + }) + + t.Run("Uninstall will not delete folder if cannot recognize plugin structure", func(t *testing.T) { + pluginDir = t.TempDir() + err = i.Uninstall(context.Background(), pluginDir) + require.EqualError(t, err, fmt.Sprintf("tried to remove %s, but it doesn't seem to be a plugin", pluginDir)) + + _, err = os.Stat(pluginDir) + require.False(t, os.IsNotExist(err)) + }) +} + +func TestExtractFiles(t *testing.T) { + i := &Installer{log: &fakeLogger{}} + pluginsDir := setupFakePluginsDir(t) + + t.Run("Should preserve file permissions for plugin backend binaries for linux and darwin", func(t *testing.T) { + skipWindows(t) + + archive := filepath.Join("testdata", "grafana-simple-json-datasource-ec18fa4da8096a952608a7e4c7782b4260b41bcf.zip") + err := i.extractFiles(archive, "grafana-simple-json-datasource", pluginsDir) + require.NoError(t, err) + + // File in zip has permissions 755 + fileInfo, err := os.Stat(filepath.Join(pluginsDir, "grafana-simple-json-datasource", "simple-plugin_darwin_amd64")) + require.NoError(t, err) + require.Equal(t, "-rwxr-xr-x", fileInfo.Mode().String()) + + // File in zip has permission 755 + fileInfo, err = os.Stat(pluginsDir + "/grafana-simple-json-datasource/simple-plugin_linux_amd64") + require.NoError(t, err) + require.Equal(t, "-rwxr-xr-x", fileInfo.Mode().String()) + + // File in zip has permission 644 + fileInfo, err = os.Stat(pluginsDir + "/grafana-simple-json-datasource/simple-plugin_windows_amd64.exe") + require.NoError(t, err) + require.Equal(t, "-rw-r--r--", fileInfo.Mode().String()) + + // File in zip has permission 755 + fileInfo, err = os.Stat(pluginsDir + "/grafana-simple-json-datasource/non-plugin-binary") + require.NoError(t, err) + require.Equal(t, "-rwxr-xr-x", fileInfo.Mode().String()) + }) + + t.Run("Should extract file with relative symlink", func(t *testing.T) { + skipWindows(t) + + err := i.extractFiles("testdata/plugin-with-symlink.zip", "plugin-with-symlink", pluginsDir) + require.NoError(t, err) + + _, err = os.Stat(pluginsDir + "/plugin-with-symlink/symlink_to_txt") + require.NoError(t, err) + + target, err := filepath.EvalSymlinks(pluginsDir + "/plugin-with-symlink/symlink_to_txt") + require.NoError(t, err) + require.Equal(t, pluginsDir+"/plugin-with-symlink/text.txt", target) + }) + + t.Run("Should extract directory with relative symlink", func(t *testing.T) { + skipWindows(t) + + err := i.extractFiles("testdata/plugin-with-symlink-dir.zip", "plugin-with-symlink-dir", pluginsDir) + require.NoError(t, err) + + _, err = os.Stat(pluginsDir + "/plugin-with-symlink-dir/symlink_to_dir") + require.NoError(t, err) + + target, err := filepath.EvalSymlinks(pluginsDir + "/plugin-with-symlink-dir/symlink_to_dir") + require.NoError(t, err) + require.Equal(t, pluginsDir+"/plugin-with-symlink-dir/dir", target) + }) + + t.Run("Should not extract file with absolute symlink", func(t *testing.T) { + skipWindows(t) + + err := i.extractFiles("testdata/plugin-with-absolute-symlink.zip", "plugin-with-absolute-symlink", pluginsDir) + require.NoError(t, err) + + _, err = os.Stat(pluginsDir + "/plugin-with-absolute-symlink/test.txt") + require.True(t, os.IsNotExist(err)) + }) + + t.Run("Should not extract directory with absolute symlink", func(t *testing.T) { + skipWindows(t) + + err := i.extractFiles("testdata/plugin-with-absolute-symlink-dir.zip", "plugin-with-absolute-symlink-dir", pluginsDir) + require.NoError(t, err) + + _, err = os.Stat(pluginsDir + "/plugin-with-absolute-symlink-dir/target") + require.True(t, os.IsNotExist(err)) + }) + + t.Run("Should detect if archive members point outside of the destination directory", func(t *testing.T) { + err := i.extractFiles("testdata/plugin-with-parent-member.zip", "plugin-with-parent-member", pluginsDir) + require.EqualError(t, err, fmt.Sprintf( + `archive member "../member.txt" tries to write outside of plugin directory: %q, this can be a security risk`, + pluginsDir, + )) + }) + + t.Run("Should detect if archive members are absolute", func(t *testing.T) { + err := i.extractFiles("testdata/plugin-with-absolute-member.zip", "plugin-with-absolute-member", pluginsDir) + require.EqualError(t, err, fmt.Sprintf( + `archive member "/member.txt" tries to write outside of plugin directory: %q, this can be a security risk`, + pluginsDir, + )) + }) +} + +func TestSelectVersion(t *testing.T) { + i := &Installer{log: &fakeLogger{}} + + t.Run("Should return error when requested version does not exist", func(t *testing.T) { + _, err := i.selectVersion(createPlugin(versionArg{version: "version"}), "1.1.1") + require.Error(t, err) + }) + + t.Run("Should return error when no version supports current arch", func(t *testing.T) { + _, err := i.selectVersion(createPlugin(versionArg{version: "version", arch: []string{"non-existent"}}), "") + require.Error(t, err) + }) + + t.Run("Should return error when requested version does not support current arch", func(t *testing.T) { + _, err := i.selectVersion(createPlugin( + versionArg{version: "2.0.0"}, + versionArg{version: "1.1.1", arch: []string{"non-existent"}}, + ), "1.1.1") + require.Error(t, err) + }) + + t.Run("Should return latest available for arch when no version specified", func(t *testing.T) { + ver, err := i.selectVersion(createPlugin( + versionArg{version: "2.0.0", arch: []string{"non-existent"}}, + versionArg{version: "1.0.0"}, + ), "") + require.NoError(t, err) + require.Equal(t, "1.0.0", ver.Version) + }) + + t.Run("Should return latest version when no version specified", func(t *testing.T) { + ver, err := i.selectVersion(createPlugin(versionArg{version: "2.0.0"}, versionArg{version: "1.0.0"}), "") + require.NoError(t, err) + require.Equal(t, "2.0.0", ver.Version) + }) + + t.Run("Should return requested version", func(t *testing.T) { + ver, err := i.selectVersion(createPlugin(versionArg{version: "2.0.0"}, versionArg{version: "1.0.0"}), "1.0.0") + require.NoError(t, err) + require.Equal(t, "1.0.0", ver.Version) + }) +} + +func TestRemoveGitBuildFromName(t *testing.T) { + // The root directory should get renamed to the plugin name + paths := map[string]string{ + "datasource-plugin-kairosdb-cc4a3965ef5d3eb1ae0ee4f93e9e78ec7db69e64/": "datasource-kairosdb/", + "datasource-plugin-kairosdb-cc4a3965ef5d3eb1ae0ee4f93e9e78ec7db69e64/README.md": "datasource-kairosdb/README.md", + "datasource-plugin-kairosdb-cc4a3965ef5d3eb1ae0ee4f93e9e78ec7db69e64/partials/": "datasource-kairosdb/partials/", + "datasource-plugin-kairosdb-cc4a3965ef5d3eb1ae0ee4f93e9e78ec7db69e64/partials/config.html": "datasource-kairosdb/partials/config.html", + } + for p, exp := range paths { + name := removeGitBuildFromName(p, "datasource-kairosdb") + require.Equal(t, exp, name) + } +} + +func TestIsSymlinkRelativeTo(t *testing.T) { + tcs := []struct { + desc string + basePath string + symlinkDestPath string + symlinkOrigPath string + expected bool + }{ + { + desc: "Symbolic link pointing to relative file within basePath should return true", + basePath: "/dir", + symlinkDestPath: "test.txt", + symlinkOrigPath: "/dir/sub-dir/test1.txt", + expected: true, + }, + { + desc: "Symbolic link pointing to relative file within basePath should return true", + basePath: "/dir", + symlinkDestPath: "test.txt", + symlinkOrigPath: "/dir/test1.txt", + expected: true, + }, + { + desc: "Symbolic link pointing to relative file within basePath should return true", + basePath: "/dir", + symlinkDestPath: "../etc/test.txt", + symlinkOrigPath: "/dir/sub-dir/test1.txt", + expected: true, + }, + { + desc: "Symbolic link pointing to absolute directory outside basePath should return false", + basePath: "/dir", + symlinkDestPath: "/etc/test.txt", + symlinkOrigPath: "/dir/sub-dir/test1.txt", + expected: false, + }, + { + desc: "Symbolic link pointing to relative file outside basePath should return false", + basePath: "/dir", + symlinkDestPath: "../../etc/test.txt", + symlinkOrigPath: "/dir/sub-dir/test1.txt", + expected: false, + }, + } + + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + actual := isSymlinkRelativeTo(tc.basePath, tc.symlinkDestPath, tc.symlinkOrigPath) + require.Equal(t, tc.expected, actual) + }) + } +} + +func setupFakePluginsDir(t *testing.T) string { + dir := "testdata/fake-plugins-dir" + err := os.RemoveAll(dir) + require.NoError(t, err) + + err = os.MkdirAll(dir, 0750) + require.NoError(t, err) + t.Cleanup(func() { + err = os.RemoveAll(dir) + require.NoError(t, err) + }) + + dir, err = filepath.Abs(dir) + require.NoError(t, err) + + return dir +} + +func skipWindows(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Skipping test on Windows") + } +} + +type versionArg struct { + version string + arch []string +} + +func createPlugin(versions ...versionArg) *Plugin { + p := &Plugin{ + Versions: []Version{}, + } + + for _, version := range versions { + ver := Version{ + Version: version.version, + Commit: fmt.Sprintf("commit_%s", version.version), + URL: fmt.Sprintf("url_%s", version.version), + } + if version.arch != nil { + ver.Arch = map[string]ArchMeta{} + for _, arch := range version.arch { + ver.Arch[arch] = ArchMeta{ + SHA256: fmt.Sprintf("sha256_%s", arch), + } + } + } + p.Versions = append(p.Versions, ver) + } + + return p +} + +type fakeLogger struct{} + +func (f *fakeLogger) Successf(_ string, _ ...interface{}) {} +func (f *fakeLogger) Failuref(_ string, _ ...interface{}) {} +func (f *fakeLogger) Info(_ ...interface{}) {} +func (f *fakeLogger) Infof(_ string, _ ...interface{}) {} +func (f *fakeLogger) Debug(_ ...interface{}) {} +func (f *fakeLogger) Debugf(_ string, _ ...interface{}) {} +func (f *fakeLogger) Warn(_ ...interface{}) {} +func (f *fakeLogger) Warnf(_ string, _ ...interface{}) {} +func (f *fakeLogger) Error(_ ...interface{}) {} +func (f *fakeLogger) Errorf(_ string, _ ...interface{}) {} diff --git a/pkg/cmd/grafana-cli/commands/testdata/grafana-simple-json-datasource-ec18fa4da8096a952608a7e4c7782b4260b41bcf.zip b/pkg/plugins/manager/installer/testdata/grafana-simple-json-datasource-ec18fa4da8096a952608a7e4c7782b4260b41bcf.zip similarity index 100% rename from pkg/cmd/grafana-cli/commands/testdata/grafana-simple-json-datasource-ec18fa4da8096a952608a7e4c7782b4260b41bcf.zip rename to pkg/plugins/manager/installer/testdata/grafana-simple-json-datasource-ec18fa4da8096a952608a7e4c7782b4260b41bcf.zip diff --git a/pkg/cmd/grafana-cli/commands/testdata/plugin-with-absolute-member.zip b/pkg/plugins/manager/installer/testdata/plugin-with-absolute-member.zip similarity index 100% rename from pkg/cmd/grafana-cli/commands/testdata/plugin-with-absolute-member.zip rename to pkg/plugins/manager/installer/testdata/plugin-with-absolute-member.zip diff --git a/pkg/plugins/manager/installer/testdata/plugin-with-absolute-symlink-dir.zip b/pkg/plugins/manager/installer/testdata/plugin-with-absolute-symlink-dir.zip new file mode 100644 index 00000000000..fd072d31ef1 Binary files /dev/null and b/pkg/plugins/manager/installer/testdata/plugin-with-absolute-symlink-dir.zip differ diff --git a/pkg/plugins/manager/installer/testdata/plugin-with-absolute-symlink.zip b/pkg/plugins/manager/installer/testdata/plugin-with-absolute-symlink.zip new file mode 100644 index 00000000000..36140f38fc0 Binary files /dev/null and b/pkg/plugins/manager/installer/testdata/plugin-with-absolute-symlink.zip differ diff --git a/pkg/cmd/grafana-cli/commands/testdata/plugin-with-parent-member.zip b/pkg/plugins/manager/installer/testdata/plugin-with-parent-member.zip similarity index 100% rename from pkg/cmd/grafana-cli/commands/testdata/plugin-with-parent-member.zip rename to pkg/plugins/manager/installer/testdata/plugin-with-parent-member.zip diff --git a/pkg/plugins/manager/installer/testdata/plugin-with-symlink-dir.zip b/pkg/plugins/manager/installer/testdata/plugin-with-symlink-dir.zip new file mode 100644 index 00000000000..85f3cd29a53 Binary files /dev/null and b/pkg/plugins/manager/installer/testdata/plugin-with-symlink-dir.zip differ diff --git a/pkg/cmd/grafana-cli/commands/testdata/plugin-with-symlink.zip b/pkg/plugins/manager/installer/testdata/plugin-with-symlink.zip similarity index 100% rename from pkg/cmd/grafana-cli/commands/testdata/plugin-with-symlink.zip rename to pkg/plugins/manager/installer/testdata/plugin-with-symlink.zip diff --git a/pkg/plugins/manager/installer/testdata/plugin-with-symlinks.zip b/pkg/plugins/manager/installer/testdata/plugin-with-symlinks.zip new file mode 100644 index 00000000000..f8f506cefb2 Binary files /dev/null and b/pkg/plugins/manager/installer/testdata/plugin-with-symlinks.zip differ