[v8.5.x] CLI: Allow relative symlinks in zip archives when installing plugins (#52049)

* resolve conflicts

* fix build issue

Co-authored-by: Marcus Efraimsson <marcus.efraimsson@gmail.com>
This commit is contained in:
Will Browne
2022-07-11 16:28:56 +02:00
committed by GitHub
co-authored by Marcus Efraimsson
parent e62f2a7b74
commit 022d7c14d9
14 changed files with 413 additions and 580 deletions
+5 -275
View File
@@ -1,27 +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/util/errutil"
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
)
func validateInput(c utils.CommandLine, pluginFolder string) error {
@@ -58,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 errutil.Wrap("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 errutil.Wrap("failed to download plugin archive", err)
}
err = tmpFile.Close()
if err != nil {
return errutil.Wrap("failed to close tmp file", err)
}
err = extractFiles(tmpFile.Name(), pluginName, pluginFolder, isInternal)
if err != nil {
return errutil.Wrap("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 errutil.Wrapf(err, "failed to install plugin '%s'", v.ID)
}
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 {
@@ -183,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 errutil.Wrap("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 errutil.Wrap("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 errutil.Wrap("failed to extract file", err)
}
buf := new(bytes.Buffer)
if _, err := io.Copy(buf, src); err != nil {
return errutil.Wrap("failed to copy symlink contents", err)
}
if err := os.Symlink(strings.TrimSpace(buf.String()), filePath); err != nil {
return errutil.Wrapf(err, "failed to make symbolic link for %v", filePath)
}
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 errutil.Wrap("failed to open file", err)
}
defer func() {
err = dst.Close()
}()
src, err := file.Open()
if err != nil {
return errutil.Wrap("failed to extract file", err)
}
defer func() {
err = src.Close()
}()
_, err = io.Copy(dst, src)
return err
}
@@ -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
}
@@ -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
}
@@ -28,7 +28,7 @@ func (cmd Command) upgradeCommand(c utils.CommandLine) error {
return errutil.Wrapf(err, "failed to remove plugin '%s'", pluginName)
}
return InstallPlugin(pluginName, "", c, cmd.Client)
return InstallPlugin(pluginName, "", c)
}
logger.Infof("%s %s is up to date \n", color.GreenString("✔"), pluginName)
+48 -35
View File
@@ -24,7 +24,6 @@ import (
"time"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/util/errutil"
)
type Installer struct {
@@ -80,7 +79,7 @@ func (e ErrVersionNotFound) Error() string {
return fmt.Sprintf("%s v%s either does not exist or is not supported on your system (%s)", e.PluginID, e.RequestedVersion, e.SystemInfo)
}
func New(skipTLSVerify bool, grafanaVersion string, logger Logger) plugins.Installer {
func New(skipTLSVerify bool, grafanaVersion string, logger Logger) *Installer {
return &Installer{
httpClient: makeHttpClient(skipTLSVerify, 10*time.Second),
httpClientNoTimeout: makeHttpClient(skipTLSVerify, 0),
@@ -92,17 +91,8 @@ func New(skipTLSVerify bool, grafanaVersion string, logger Logger) plugins.Insta
// 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
@@ -137,7 +127,7 @@ func (i *Installer) Install(ctx context.Context, pluginID, version, pluginsDir,
// Create temp file for downloading zip file
tmpFile, err := ioutil.TempFile("", "*.zip")
if err != nil {
return errutil.Wrap("failed to create temporary file", err)
return fmt.Errorf("%v: %w", "failed to create temporary file", err)
}
defer func() {
if err := os.Remove(tmpFile.Name()); err != nil {
@@ -150,16 +140,16 @@ func (i *Installer) Install(ctx context.Context, pluginID, version, pluginsDir,
if err := tmpFile.Close(); err != nil {
i.log.Warn("Failed to close file", "err", err)
}
return errutil.Wrap("failed to download plugin archive", err)
return fmt.Errorf("%v: %w", "failed to download plugin archive", err)
}
err = tmpFile.Close()
if err != nil {
return errutil.Wrap("failed to close tmp file", err)
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 errutil.Wrap("failed to extract plugin archive", err)
return fmt.Errorf("%v: %w", "failed to extract plugin archive", err)
}
res, _ := toPluginDTO(pluginsDir, pluginID)
@@ -170,7 +160,7 @@ func (i *Installer) Install(ctx context.Context, pluginID, version, pluginsDir,
for _, dep := range res.Dependencies.Plugins {
i.log.Infof("Fetching %s dependencies...", res.ID)
if err := i.Install(ctx, dep.ID, normalizeVersion(dep.Version), pluginsDir, "", pluginRepoURL); err != nil {
return errutil.Wrapf(err, "failed to install plugin %s", dep.ID)
return fmt.Errorf("failed to install plugin %s: %w", dep.ID, err)
}
}
@@ -203,7 +193,7 @@ func (i *Installer) DownloadFile(pluginID string, tmpFile *os.File, url string,
// nolint:gosec
f, err := os.Open(url)
if err != nil {
return errutil.Wrap("Failed to read plugin archive", err)
return fmt.Errorf("%v: %w", "Failed to read plugin archive", err)
}
defer func() {
if err := f.Close(); err != nil {
@@ -212,7 +202,7 @@ func (i *Installer) DownloadFile(pluginID string, tmpFile *os.File, url string,
}()
_, err = io.Copy(tmpFile, f)
if err != nil {
return errutil.Wrap("Failed to copy plugin archive", err)
return fmt.Errorf("%v: %w", "Failed to copy plugin archive", err)
}
return nil
}
@@ -260,7 +250,7 @@ func (i *Installer) DownloadFile(pluginID string, tmpFile *os.File, url string,
w := bufio.NewWriter(tmpFile)
h := sha256.New()
if _, err = io.Copy(w, io.TeeReader(bodyReader, h)); err != nil {
return errutil.Wrap("failed to compute SHA256 checksum", err)
return fmt.Errorf("%v: %w", "failed to compute SHA256 checksum", err)
}
if err := w.Flush(); err != nil {
return fmt.Errorf("failed to write to %q: %w", tmpFile.Name(), err)
@@ -509,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 {
@@ -571,15 +561,11 @@ func (i *Installer) extractFiles(archiveFile string, pluginID string, dest strin
// 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 errutil.Wrap("failed to create directory to extract plugin files", err)
return fmt.Errorf("%v: %w", "failed to create directory to extract plugin files", err)
}
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
}
@@ -587,7 +573,7 @@ func (i *Installer) extractFiles(archiveFile string, pluginID string, dest strin
}
if err := extractFile(zf, dstPath); err != nil {
return errutil.Wrap("failed to extract file", err)
return fmt.Errorf("%v: %w", "failed to extract file", err)
}
}
@@ -598,22 +584,49 @@ 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 {
return errutil.Wrap("failed to extract file", err)
return fmt.Errorf("%v: %w", "failed to extract file", err)
}
buf := new(bytes.Buffer)
if _, err := io.Copy(buf, src); err != nil {
return errutil.Wrap("failed to copy symlink contents", err)
return fmt.Errorf("%v: %w", "failed to copy symlink contents", err)
}
if err := os.Symlink(strings.TrimSpace(buf.String()), filePath); err != nil {
return errutil.Wrapf(err, "failed to make symbolic link for %v", filePath)
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
@@ -636,7 +649,7 @@ func extractFile(file *zip.File, filePath string) (err error) {
return fmt.Errorf("file %q is in use - please stop Grafana, install the plugin and restart Grafana", filePath)
}
return errutil.Wrap("failed to open file", err)
return fmt.Errorf("%v: %w", "failed to open file", err)
}
defer func() {
err = dst.Close()
@@ -644,7 +657,7 @@ func extractFile(file *zip.File, filePath string) (err error) {
src, err := file.Open()
if err != nil {
return errutil.Wrap("failed to extract file", err)
return fmt.Errorf("%v: %w", "failed to extract file", err)
}
defer func() {
err = src.Close()
@@ -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{}) {}
Binary file not shown.