From b47ec36d0d72e3d86b97cc2f72b87c18b31c9ac0 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Wed, 15 Jun 2022 14:38:59 +0200 Subject: [PATCH] CLI: Allow relative symlinks in zip archives when installing plugins (#50537) Earlier we only allowed symlinks in plugins starting with grafana- in zip archives when installing plugins using the grafana-cli. This changes so that symlinks in zip archives containing relative links to files in the zip archive are always allowed when installing plugins. The reasoning behind this is that Grafana per default doesn't load a plugin that has an invalid plugin signature meaning that any symlink must be included in the plugin signature manifest. Co-authored-by: Will Browne Co-authored-by: Will Browne --- .../grafana-cli/commands/install_command.go | 279 +------------- .../commands/install_command_test.go | 268 ------------- .../commands/upgrade_all_command.go | 2 +- .../grafana-cli/commands/upgrade_command.go | 2 +- pkg/plugins/manager/installer/installer.go | 50 ++- .../manager/installer/installer_test.go | 358 ++++++++++++++++++ ...18fa4da8096a952608a7e4c7782b4260b41bcf.zip | Bin .../testdata/plugin-with-absolute-member.zip | Bin .../plugin-with-absolute-symlink-dir.zip | Bin 0 -> 451 bytes .../testdata/plugin-with-absolute-symlink.zip | Bin 0 -> 444 bytes .../testdata/plugin-with-parent-member.zip | Bin .../testdata/plugin-with-symlink-dir.zip | Bin 0 -> 912 bytes .../testdata/plugin-with-symlink.zip | Bin .../testdata/plugin-with-symlinks.zip | Bin 0 -> 3407 bytes 14 files changed, 397 insertions(+), 562 deletions(-) delete mode 100644 pkg/cmd/grafana-cli/commands/install_command_test.go create mode 100644 pkg/plugins/manager/installer/installer_test.go rename pkg/{cmd/grafana-cli/commands => plugins/manager/installer}/testdata/grafana-simple-json-datasource-ec18fa4da8096a952608a7e4c7782b4260b41bcf.zip (100%) rename pkg/{cmd/grafana-cli/commands => plugins/manager/installer}/testdata/plugin-with-absolute-member.zip (100%) create mode 100644 pkg/plugins/manager/installer/testdata/plugin-with-absolute-symlink-dir.zip create mode 100644 pkg/plugins/manager/installer/testdata/plugin-with-absolute-symlink.zip rename pkg/{cmd/grafana-cli/commands => plugins/manager/installer}/testdata/plugin-with-parent-member.zip (100%) create mode 100644 pkg/plugins/manager/installer/testdata/plugin-with-symlink-dir.zip rename pkg/{cmd/grafana-cli/commands => plugins/manager/installer}/testdata/plugin-with-symlink.zip (100%) create mode 100644 pkg/plugins/manager/installer/testdata/plugin-with-symlinks.zip 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 - - 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) - } +func InstallPlugin(pluginID, version string, c utils.CommandLine) error { + skipTLSVerify := c.Bool("insecure") - 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 0000000000000000000000000000000000000000..fd072d31ef1757fde93689c75326324e5b6f398a GIT binary patch literal 451 zcmWIWW@Zs#0D&iM=R&{?D51!pz)+A=nx2`bTb@~xp_`agoS#!#lB!!=nVXZDm#v$U zS)?Bt!pp$!yKqGk43}1LGcdBeU}j(d69I7JZvu_?=-p(`3N#*s)rm2_B(W$xwFGR5 zHP8|mMzcgewIo?Tz?+dtgc-M2U|##{2x3veV+iZ8c@1XW|Aq0ySVx>k1H4(;Kn61b M;S(Tz62xHu08>VG)c^nh literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..36140f38fc016afa1c3fb09866635c6f055327bf GIT binary patch literal 444 zcmWIWW@Zs#0D;pn=R&{?C?U(Bz)+A=nx2`bTb@~xp_`agoS#!#lB!!=nVXZDm#rTf z!pp$U7rr71hD$5B85mh!Ff%ZKi2xL%r%G2EasiD7VO2s#m!uY#=#^BI5VAr)wIo?T zBfq$$IKZ2cNrV}j+{|=r2^O(0vB8>i@!MLRJy$&j4>$HjuGQKzI*Gp8#6951J literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..85f3cd29a5367118d8600bddf30823c535e20ad6 GIT binary patch literal 912 zcmWIWW@Zs#00GsGb0J^`l#pOhU?|8bP0!5JEzc~;&@Haa&B@Hm)=kMQ(hm*cWnkaF zXhjkXmsW5yFtWU0W?%pl0dP~rfu;&nt=H2U-HQV;<0s`9KV! z(d+;!L0H3y=6D%A*1$c`3^Wvm(F})}%)!9HfNXLf&;u$2OfE^SDA6maC;>YkWcog+ zEoe6Qb|2(rFyLVNpL3Eqtl)3gHc#Q6il&Sx(WyL}%VYBNZoC)O`WPL_z4ocI@4k@7 zVqPU`FG3B|>|RQ<2Y9n{6c@Emu?9K^hK@|CMim(M+tU~Ny zVEDf<4390O#WAwakRt+Q7buEh;IHFeAQP9*h>LKD?_g1m9KN6^hk+%H;*9uhA~pd8 Uc(byBOl1MWZ9p@R05Jmt0Q=eai2wiq literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f8f506cefb26dc2414975efe4af49bcc2d74d6bd GIT binary patch literal 3407 zcmb7G3p~^7AO6kdG>yoR6lbS{8Z~lD9n|;}xs8lS)@E)eHrZ$#mz4M`RHr!lyQMfW zt#!yEQc^KuuH{xIiRIQ3k|gIhow0n>Kkq)@{oZ{(`#sNld7kHeG1j7Dasa=n#x^+3 zb^iU501yDxi$dLk4-C}8I!OU5#u<5pDX`8J<`IHvFaRPp0Re!y*D?RXK!JV`M%{j8 zbDszR(8T~iUKqv}ZD(a+zSjvwrBUY~4>I!zc(C}-MUafWY*MeGTsf?9C$u!IQyuBJ zL24ZRtnYq7y4}X(=;H;9cDK#Q_>9Vr(=~O96llMmh&?@wIVeBnKh!laG#=N}dO@0N zC%5X#Os4L@ZU^n*EOeLBXi!VYC5K&z!Yy4NT3GA3s2)Fms_RUn`C6ZW16`$NwvA#( z?qu}ncw8wD(;Od69fFsjN_vp9Yp!CshMJFOCG0|juBmuH8${RiC1Z)M6Z?j1l|Id! zu49HzvXVH?^v;fLnOv9G(@)e3-ghg9#UNr3|4}SiLo7I*y`w22QT~j|BqTTU%FLnM z_#+>3!W&)?r&5PsP^SxOBe8LVPBjUqRL8TmP5RDn+HitLu_?XVjxi0V>k||Y`>N{N zSo@!km82vlLQ*|qNM`!Dx>He{E0Wrx$j7oYmEPL8_KlsrwLix~UhJun41uh^wre6; zqACYhXQi&V`_*HYwkH}o9(jdoMS5+8bhrk2hbd(cnRLS2L?%Ha-gqT)m%Ge{WCPSg zjFk(_B}~jZ`Y1;m+sMQocpjO1SsGipKi;nJ1r7dI?Jo4UR6KN3O1f?yz+^nub#&l} zO8A(Jx$i37edcO$F-)!6!V-U@LIN8+-9MZBDW8jDVY9THRreXAW;>FGrpdn)xnI%G z&a4ZqwuKIQ?ti-3it1LJVT9#)w!YpSLf|~)xK-op(gTrdiRRTF@)voA`x+P zNjvJ1f(@-x3is^g-jaP04N3OCWtgJ+z+30W{5*>dhXbr*(_#)X;a7WoU*3+Jl!gPTafSx=LFvlE;LGzVREn zwc(hk_+iAcn{l_}EmJa!J33WW^WFB$mRbET#BSRghVkEh;oR{Xi8A;8JbQEv6pkFd zFK?J1ju-E|n3i3u(=@B zRkzf~Qi8U|#@KJ~#l3P4BvoIZ8Ry(iTxopyH;eK%1DDiyxOb;95^d>$*NhZxE?)IOag1Q@Mr}J zo`+*D4QG3*QVP(9ip=`6MV$j@PO_}v3AAIrZt}^JMMvC8sCJ&b#^`=m7^Bb3w!*1b zVGmXP6?Ut)$-uf5Uyh`hKIs-v6k`3BW5!}{)NeU&enfA#nz_X(ryP{l zfu0Ps@vJ`??tQ*yDy-PCi_;=;vjuVdUPW4KPn^+OhWWajrpOm|;Z|9-L?q8)bOb$q zU7nP(Jxd84$6>?l)~-}zoE>QA#T>f3-OyXwN3B5dP8oyFzOHK_(P$gE6{l|R@?piM zEJ#BOC4K7CMiZD@p7|Z33zD#h3#>jMmL|Ea>Z9n-#%T{jv06V8(`yllXZ z(pOe@PH>;^8G=~*zxuH}rB!JJVp@Qu_D$RXuK;pT=yz>fLBAsgqru+Hw!#)VV%}PF z9u;8T*w)u;(VdzhR`R(iUJ5?_T!>fVU6BXuwfUv-_m0>`;`DTCwjLq>~d~f!XsbmC=yVNWJiJL$Nb2bcQ7rGEPW&iNOGdP zg-MoiKrJvVoreO3k?cxwp=o?qIxfZ*VDX>)JQTDMwDJ!^*g^?1A7@`p$Ax|7Z^Ghy z6fopOlZ6?+H!T+?@Hu`Lhu#AGlrX%|1eP0t3yez#fq-!Y?E>C;=2a!c_`R|4<-#ov g1_4h_H1nH0%ZLe#wK%v)0I(AL41sF3li>gQ2lPeQ$p8QV literal 0 HcmV?d00001