Plugins: Unexport PluginDir field from PluginDTO (#59190)

* unexport pluginDir from dto

* more err checks

* tidy

* fix tests

* fix dboard file tests

* fix import

* fix tests

* apply PR feedback

* combine interfaces

* fix logs and clean up test

* filepath clean

* use fs.File

* rm explicit type
pull/59720/head
Will Browne 3 years ago committed by GitHub
parent 6d1bcd9f40
commit 76233f9997
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 81
      pkg/api/plugins.go
  2. 166
      pkg/api/plugins_test.go
  3. 47
      pkg/plugins/backendplugin/provider/provider.go
  4. 11
      pkg/plugins/manager/dashboards/filestore.go
  5. 73
      pkg/plugins/manager/dashboards/filestore_test.go
  6. 6
      pkg/plugins/manager/installer.go
  7. 3
      pkg/plugins/manager/installer_test.go
  8. 1
      pkg/plugins/manager/loader/loader.go
  9. 19
      pkg/plugins/manager/manager_integration_test.go
  10. 7
      pkg/plugins/manager/signature/manifest.go
  11. 2
      pkg/plugins/manager/signature/signature.go
  12. 8
      pkg/plugins/models.go
  13. 76
      pkg/plugins/plugins.go
  14. 2
      pkg/services/plugindashboards/service/service.go
  15. 3
      pkg/services/plugindashboards/service/service_test.go

@ -1,12 +1,13 @@
package api package api
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"os"
"path" "path"
"path/filepath" "path/filepath"
"runtime" "runtime"
@ -16,7 +17,6 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/infra/fs"
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/plugins/backendplugin"
@ -28,6 +28,7 @@ import (
"github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/pluginsettings" "github.com/grafana/grafana/pkg/services/pluginsettings"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web" "github.com/grafana/grafana/pkg/web"
) )
@ -309,42 +310,25 @@ func (hs *HTTPServer) getPluginAssets(c *models.ReqContext) {
} }
// prepend slash for cleaning relative paths // prepend slash for cleaning relative paths
requestedFile := filepath.Clean(filepath.Join("/", web.Params(c.Req)["*"])) requestedFile, err := util.CleanRelativePath(web.Params(c.Req)["*"])
rel, err := filepath.Rel("/", requestedFile)
if err != nil { if err != nil {
// slash is prepended above therefore this is not expected to fail // slash is prepended above therefore this is not expected to fail
c.JsonApiErr(500, "Failed to get the relative path", err) c.JsonApiErr(500, "Failed to clean relative file path", err)
return return
} }
if !plugin.IncludedInSignature(rel) { f, err := plugin.File(requestedFile)
hs.log.Warn("Access to requested plugin file will be forbidden in upcoming Grafana versions as the file "+
"is not included in the plugin signature", "file", requestedFile)
}
absPluginDir, err := filepath.Abs(plugin.PluginDir)
if err != nil {
c.JsonApiErr(500, "Failed to get plugin absolute path", nil)
return
}
pluginFilePath := filepath.Join(absPluginDir, rel)
// It's safe to ignore gosec warning G304 since we already clean the requested file path and subsequently
// use this with a prefix of the plugin's directory, which is set during plugin loading
// nolint:gosec
f, err := os.Open(pluginFilePath)
if err != nil { if err != nil {
if os.IsNotExist(err) { if errors.Is(err, plugins.ErrFileNotExist) {
c.JsonApiErr(404, "Plugin file not found", err) c.JsonApiErr(404, "Plugin file not found", nil)
return return
} }
c.JsonApiErr(500, "Could not open plugin file", err) c.JsonApiErr(500, "Could not open plugin file", err)
return return
} }
defer func() { defer func() {
if err := f.Close(); err != nil { if err = f.Close(); err != nil {
hs.log.Error("Failed to close file", "err", err) hs.log.Error("Failed to close plugin file", "err", err)
} }
}() }()
@ -360,7 +344,16 @@ func (hs *HTTPServer) getPluginAssets(c *models.ReqContext) {
c.Resp.Header().Set("Cache-Control", "public, max-age=3600") c.Resp.Header().Set("Cache-Control", "public, max-age=3600")
} }
http.ServeContent(c.Resp, c.Req, pluginFilePath, fi.ModTime(), f) if rs, ok := f.(io.ReadSeeker); ok {
http.ServeContent(c.Resp, c.Req, requestedFile, fi.ModTime(), rs)
} else {
b, err := io.ReadAll(f)
if err != nil {
c.JsonApiErr(500, "Plugin file exists but could not read", err)
return
}
http.ServeContent(c.Resp, c.Req, requestedFile, fi.ModTime(), bytes.NewReader(b))
}
} }
// CheckHealth returns the health of a plugin. // CheckHealth returns the health of a plugin.
@ -496,34 +489,24 @@ func (hs *HTTPServer) pluginMarkdown(ctx context.Context, pluginId string, name
return nil, plugins.NotFoundError{PluginID: pluginId} return nil, plugins.NotFoundError{PluginID: pluginId}
} }
// nolint:gosec md, err := plugin.File(mdFilepath(strings.ToUpper(name)))
// We can ignore the gosec G304 warning since we have cleaned the requested file path and subsequently
// use this with a prefix of the plugin's directory, which is set during plugin loading
path := filepath.Join(plugin.PluginDir, mdFilepath(strings.ToUpper(name)))
exists, err := fs.Exists(path)
if err != nil { if err != nil {
return nil, err md, err = plugin.File(mdFilepath(strings.ToUpper(name)))
} if err != nil {
if !exists { return make([]byte, 0), nil
path = filepath.Join(plugin.PluginDir, mdFilepath(strings.ToLower(name))) }
} }
defer func() {
if err = md.Close(); err != nil {
hs.log.Error("Failed to close plugin markdown file", "err", err)
}
}()
exists, err = fs.Exists(path) d, err := io.ReadAll(md)
if err != nil { if err != nil {
return nil, err
}
if !exists {
return make([]byte, 0), nil return make([]byte, 0), nil
} }
return d, nil
// nolint:gosec
// We can ignore the gosec G304 warning since we have cleaned the requested file path and subsequently
// use this with a prefix of the plugin's directory, which is set during plugin loading
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return data, nil
} }
func mdFilepath(mdFilename string) string { func mdFilepath(mdFilename string) string {

@ -12,7 +12,6 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend"
@ -135,13 +134,13 @@ func Test_PluginsInstallAndUninstall_AccessControl(t *testing.T) {
t.Run(testName("Install", tc), func(t *testing.T) { t.Run(testName("Install", tc), func(t *testing.T) {
input := strings.NewReader("{ \"version\": \"1.0.2\" }") input := strings.NewReader("{ \"version\": \"1.0.2\" }")
response := callAPI(sc.server, http.MethodPost, "/api/plugins/test/install", input, t) response := callAPI(sc.server, http.MethodPost, "/api/plugins/test/install", input, t)
assert.Equal(t, tc.expectedCode, response.Code) require.Equal(t, tc.expectedCode, response.Code)
}) })
t.Run(testName("Uninstall", tc), func(t *testing.T) { t.Run(testName("Uninstall", tc), func(t *testing.T) {
input := strings.NewReader("{ }") input := strings.NewReader("{ }")
response := callAPI(sc.server, http.MethodPost, "/api/plugins/test/uninstall", input, t) response := callAPI(sc.server, http.MethodPost, "/api/plugins/test/uninstall", input, t)
assert.Equal(t, tc.expectedCode, response.Code) require.Equal(t, tc.expectedCode, response.Code)
}) })
} }
} }
@ -155,56 +154,45 @@ func Test_GetPluginAssets(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { t.Cleanup(func() {
err := os.RemoveAll(tmpFile.Name()) err := os.RemoveAll(tmpFile.Name())
assert.NoError(t, err) require.NoError(t, err)
err = os.RemoveAll(tmpFileInParentDir.Name()) err = os.RemoveAll(tmpFileInParentDir.Name())
assert.NoError(t, err) require.NoError(t, err)
}) })
expectedBody := "Plugin test" expectedBody := "Plugin test"
_, err = tmpFile.WriteString(expectedBody) _, err = tmpFile.WriteString(expectedBody)
assert.NoError(t, err) require.NoError(t, err)
requestedFile := filepath.Clean(tmpFile.Name()) requestedFile := filepath.Clean(tmpFile.Name())
t.Run("Given a request for an existing plugin file that is listed as a signature covered file", func(t *testing.T) { t.Run("Given a request for an existing plugin file", func(t *testing.T) {
p := plugins.PluginDTO{ p := &plugins.Plugin{
JSONData: plugins.JSONData{ JSONData: plugins.JSONData{
ID: pluginID, ID: pluginID,
}, },
PluginDir: pluginDir, PluginDir: pluginDir,
SignedFiles: map[string]struct{}{
requestedFile: {},
},
} }
service := &plugins.FakePluginStore{ service := &plugins.FakePluginStore{
PluginList: []plugins.PluginDTO{p}, PluginList: []plugins.PluginDTO{p.ToDTO()},
} }
l := &logtest.Fake{}
url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile) url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile)
pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", service, l, pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", service,
func(sc *scenarioContext) { func(sc *scenarioContext) {
callGetPluginAsset(sc) callGetPluginAsset(sc)
require.Equal(t, 200, sc.resp.Code) require.Equal(t, 200, sc.resp.Code)
assert.Equal(t, expectedBody, sc.resp.Body.String()) require.Equal(t, expectedBody, sc.resp.Body.String())
assert.Zero(t, l.WarnLogs.Calls)
}) })
}) })
t.Run("Given a request for a relative path", func(t *testing.T) { t.Run("Given a request for a relative path", func(t *testing.T) {
p := plugins.PluginDTO{ p := createPluginDTO(plugins.JSONData{ID: pluginID}, plugins.External, pluginDir)
JSONData: plugins.JSONData{
ID: pluginID,
},
PluginDir: pluginDir,
}
service := &plugins.FakePluginStore{ service := &plugins.FakePluginStore{
PluginList: []plugins.PluginDTO{p}, PluginList: []plugins.PluginDTO{p},
} }
l := &logtest.Fake{}
url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, tmpFileInParentDir.Name()) url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, tmpFileInParentDir.Name())
pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", service, l, pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", service,
func(sc *scenarioContext) { func(sc *scenarioContext) {
callGetPluginAsset(sc) callGetPluginAsset(sc)
@ -212,44 +200,15 @@ func Test_GetPluginAssets(t *testing.T) {
}) })
}) })
t.Run("Given a request for an existing plugin file that is not listed as a signature covered file", func(t *testing.T) {
p := plugins.PluginDTO{
JSONData: plugins.JSONData{
ID: pluginID,
},
PluginDir: pluginDir,
}
service := &plugins.FakePluginStore{
PluginList: []plugins.PluginDTO{p},
}
l := &logtest.Fake{}
url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile)
pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", service, l,
func(sc *scenarioContext) {
callGetPluginAsset(sc)
require.Equal(t, 200, sc.resp.Code)
assert.Equal(t, expectedBody, sc.resp.Body.String())
assert.Zero(t, l.WarnLogs.Calls)
})
})
t.Run("Given a request for an non-existing plugin file", func(t *testing.T) { t.Run("Given a request for an non-existing plugin file", func(t *testing.T) {
p := plugins.PluginDTO{ p := createPluginDTO(plugins.JSONData{ID: pluginID}, plugins.External, pluginDir)
JSONData: plugins.JSONData{
ID: pluginID,
},
PluginDir: pluginDir,
}
service := &plugins.FakePluginStore{ service := &plugins.FakePluginStore{
PluginList: []plugins.PluginDTO{p}, PluginList: []plugins.PluginDTO{p},
} }
l := &logtest.Fake{}
requestedFile := "nonExistent" requestedFile := "nonExistent"
url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile) url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile)
pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", service, l, pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", service,
func(sc *scenarioContext) { func(sc *scenarioContext) {
callGetPluginAsset(sc) callGetPluginAsset(sc)
@ -257,8 +216,7 @@ func Test_GetPluginAssets(t *testing.T) {
err := json.NewDecoder(sc.resp.Body).Decode(&respJson) err := json.NewDecoder(sc.resp.Body).Decode(&respJson)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, 404, sc.resp.Code) require.Equal(t, 404, sc.resp.Code)
assert.Equal(t, "Plugin file not found", respJson["message"]) require.Equal(t, "Plugin file not found", respJson["message"])
assert.Zero(t, l.WarnLogs.Calls)
}) })
}) })
@ -270,16 +228,16 @@ func Test_GetPluginAssets(t *testing.T) {
requestedFile := "nonExistent" requestedFile := "nonExistent"
url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile) url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile)
pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", service, l, pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", service,
func(sc *scenarioContext) { func(sc *scenarioContext) {
callGetPluginAsset(sc) callGetPluginAsset(sc)
var respJson map[string]interface{} var respJson map[string]interface{}
err := json.NewDecoder(sc.resp.Body).Decode(&respJson) err := json.NewDecoder(sc.resp.Body).Decode(&respJson)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, 404, sc.resp.Code) require.Equal(t, 404, sc.resp.Code)
assert.Equal(t, "Plugin not found", respJson["message"]) require.Equal(t, "Plugin not found", respJson["message"])
assert.Zero(t, l.WarnLogs.Calls) require.Zero(t, l.WarnLogs.Calls)
}) })
}) })
@ -295,13 +253,13 @@ func Test_GetPluginAssets(t *testing.T) {
l := &logtest.Fake{} l := &logtest.Fake{}
url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile) url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile)
pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", service, l, pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", service,
func(sc *scenarioContext) { func(sc *scenarioContext) {
callGetPluginAsset(sc) callGetPluginAsset(sc)
require.Equal(t, 200, sc.resp.Code) require.Equal(t, 200, sc.resp.Code)
assert.Equal(t, expectedBody, sc.resp.Body.String()) require.Equal(t, expectedBody, sc.resp.Body.String())
assert.Zero(t, l.WarnLogs.Calls) require.Zero(t, l.WarnLogs.Calls)
}) })
}) })
} }
@ -348,7 +306,7 @@ func TestMakePluginResourceRequestSetCookieNotPresent(t *testing.T) {
break break
} }
} }
assert.Empty(t, resp.Header().Values("Set-Cookie"), "Set-Cookie header should not be present") require.Empty(t, resp.Header().Values("Set-Cookie"), "Set-Cookie header should not be present")
} }
func TestMakePluginResourceRequestContentTypeUnique(t *testing.T) { func TestMakePluginResourceRequestContentTypeUnique(t *testing.T) {
@ -382,8 +340,8 @@ func TestMakePluginResourceRequestContentTypeUnique(t *testing.T) {
break break
} }
} }
assert.Len(t, resp.Header().Values("Content-Type"), 1, "should have 1 Content-Type header") require.Len(t, resp.Header().Values("Content-Type"), 1, "should have 1 Content-Type header")
assert.Len(t, resp.Header().Values("x-another"), 1, "should have 1 X-Another header") require.Len(t, resp.Header().Values("x-another"), 1, "should have 1 X-Another header")
}) })
} }
} }
@ -417,12 +375,11 @@ func callGetPluginAsset(sc *scenarioContext) {
} }
func pluginAssetScenario(t *testing.T, desc string, url string, urlPattern string, pluginStore plugins.Store, func pluginAssetScenario(t *testing.T, desc string, url string, urlPattern string, pluginStore plugins.Store,
logger log.Logger, fn scenarioFunc) { fn scenarioFunc) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
hs := HTTPServer{ hs := HTTPServer{
Cfg: setting.NewCfg(), Cfg: setting.NewCfg(),
pluginStore: pluginStore, pluginStore: pluginStore,
log: logger,
} }
sc := setupScenarioContext(t, url) sc := setupScenarioContext(t, url)
@ -478,42 +435,40 @@ func (c *fakePluginClient) QueryData(ctx context.Context, req *backend.QueryData
} }
func Test_PluginsList_AccessControl(t *testing.T) { func Test_PluginsList_AccessControl(t *testing.T) {
pluginStore := plugins.FakePluginStore{PluginList: []plugins.PluginDTO{ p1 := &plugins.Plugin{
{ PluginDir: "/grafana/plugins/test-app/dist",
PluginDir: "/grafana/plugins/test-app/dist", Class: plugins.External,
Class: "external", DefaultNavURL: "/plugins/test-app/page/test",
DefaultNavURL: "/plugins/test-app/page/test", Signature: plugins.SignatureUnsigned,
Pinned: false, Module: "plugins/test-app/module",
Signature: "unsigned", BaseURL: "public/plugins/test-app",
Module: "plugins/test-app/module", JSONData: plugins.JSONData{
BaseURL: "public/plugins/test-app", ID: "test-app",
JSONData: plugins.JSONData{ Type: plugins.App,
ID: "test-app", Name: "test-app",
Type: "app", Info: plugins.Info{
Name: "test-app", Version: "1.0.0",
Info: plugins.Info{
Version: "1.0.0",
},
}, },
}, },
{ }
PluginDir: "/grafana/public/app/plugins/datasource/mysql", p2 := &plugins.Plugin{
Class: "core", PluginDir: "/grafana/public/app/plugins/datasource/mysql",
Pinned: false, Class: plugins.Core,
Signature: "internal", Pinned: false,
Module: "app/plugins/datasource/mysql/module", Signature: plugins.SignatureInternal,
BaseURL: "public/app/plugins/datasource/mysql", Module: "app/plugins/datasource/mysql/module",
JSONData: plugins.JSONData{ BaseURL: "public/app/plugins/datasource/mysql",
ID: "mysql", JSONData: plugins.JSONData{
Type: "datasource", ID: "mysql",
Name: "MySQL", Type: plugins.DataSource,
Info: plugins.Info{ Name: "MySQL",
Author: plugins.InfoLink{Name: "Grafana Labs", URL: "https://grafana.com"}, Info: plugins.Info{
Description: "Data source for MySQL databases", Author: plugins.InfoLink{Name: "Grafana Labs", URL: "https://grafana.com"},
}, Description: "Data source for MySQL databases",
}, },
}, },
}} }
pluginStore := plugins.FakePluginStore{PluginList: []plugins.PluginDTO{p1.ToDTO(), p2.ToDTO()}}
pluginSettings := pluginsettings.FakePluginSettings{Plugins: map[string]*pluginsettings.DTO{ pluginSettings := pluginsettings.FakePluginSettings{Plugins: map[string]*pluginsettings.DTO{
"test-app": {ID: 0, OrgID: 1, PluginID: "test-app", PluginVersion: "1.0.0", Enabled: true}, "test-app": {ID: 0, OrgID: 1, PluginID: "test-app", PluginVersion: "1.0.0", Enabled: true},
@ -574,3 +529,12 @@ func Test_PluginsList_AccessControl(t *testing.T) {
}) })
} }
} }
func createPluginDTO(jd plugins.JSONData, class plugins.Class, pluginDir string) plugins.PluginDTO {
p := &plugins.Plugin{
JSONData: jd,
Class: class,
PluginDir: pluginDir,
}
return p.ToDTO()
}

@ -2,10 +2,6 @@ package provider
import ( import (
"context" "context"
"fmt"
"path/filepath"
"runtime"
"strings"
"github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins"
@ -49,7 +45,7 @@ var RendererProvider PluginBackendProvider = func(_ context.Context, p *plugins.
if !p.IsRenderer() { if !p.IsRenderer() {
return nil return nil
} }
return grpcplugin.NewRendererPlugin(p.ID, filepath.Join(p.PluginDir, rendererStartCmd()), return grpcplugin.NewRendererPlugin(p.ID, p.ExecutablePath(),
func(pluginID string, renderer pluginextensionv2.RendererPlugin, logger log.Logger) error { func(pluginID string, renderer pluginextensionv2.RendererPlugin, logger log.Logger) error {
p.Renderer = renderer p.Renderer = renderer
return nil return nil
@ -61,7 +57,7 @@ var SecretsManagerProvider PluginBackendProvider = func(_ context.Context, p *pl
if !p.IsSecretsManager() { if !p.IsSecretsManager() {
return nil return nil
} }
return grpcplugin.NewSecretsManagerPlugin(p.ID, filepath.Join(p.PluginDir, secretsManagerStartCmd()), return grpcplugin.NewSecretsManagerPlugin(p.ID, p.ExecutablePath(),
func(pluginID string, secretsmanager secretsmanagerplugin.SecretsManagerPlugin, logger log.Logger) error { func(pluginID string, secretsmanager secretsmanagerplugin.SecretsManagerPlugin, logger log.Logger) error {
p.SecretsManager = secretsmanager p.SecretsManager = secretsmanager
return nil return nil
@ -70,42 +66,5 @@ var SecretsManagerProvider PluginBackendProvider = func(_ context.Context, p *pl
} }
var DefaultProvider PluginBackendProvider = func(_ context.Context, p *plugins.Plugin) backendplugin.PluginFactoryFunc { var DefaultProvider PluginBackendProvider = func(_ context.Context, p *plugins.Plugin) backendplugin.PluginFactoryFunc {
// TODO check for executable return grpcplugin.NewBackendPlugin(p.ID, p.ExecutablePath())
return grpcplugin.NewBackendPlugin(p.ID, filepath.Join(p.PluginDir, pluginStartCmd(p.Executable)))
}
func pluginStartCmd(executable string) string {
os := strings.ToLower(runtime.GOOS)
arch := runtime.GOARCH
extension := ""
if os == "windows" {
extension = ".exe"
}
return fmt.Sprintf("%s_%s_%s%s", executable, os, strings.ToLower(arch), extension)
}
func rendererStartCmd() string {
os := strings.ToLower(runtime.GOOS)
arch := runtime.GOARCH
extension := ""
if os == "windows" {
extension = ".exe"
}
return fmt.Sprintf("%s_%s_%s%s", "plugin_start", os, strings.ToLower(arch), extension)
}
func secretsManagerStartCmd() string {
os := strings.ToLower(runtime.GOOS)
arch := runtime.GOARCH
extension := ""
if os == "windows" {
extension = ".exe"
}
return fmt.Sprintf("%s_%s_%s%s", "secrets_plugin_start", os, strings.ToLower(arch), extension)
} }

@ -4,8 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"io/fs" "io/fs"
"os"
"path/filepath"
"strings" "strings"
"github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins"
@ -24,10 +22,8 @@ func ProvideFileStoreManager(pluginStore plugins.Store) *FileStoreManager {
} }
} }
var openDashboardFile = func(name string) (fs.File, error) { var openDashboardFile = func(p plugins.PluginDTO, name string) (fs.File, error) {
// Wrapping in filepath.Clean to properly handle return p.File(name)
// gosec G304 Potential file inclusion via variable rule.
return os.Open(filepath.Clean(name))
} }
func (m *FileStoreManager) ListPluginDashboardFiles(ctx context.Context, args *ListPluginDashboardFilesArgs) (*ListPluginDashboardFilesResult, error) { func (m *FileStoreManager) ListPluginDashboardFiles(ctx context.Context, args *ListPluginDashboardFilesArgs) (*ListPluginDashboardFilesResult, error) {
@ -90,8 +86,7 @@ func (m *FileStoreManager) GetPluginDashboardFileContents(ctx context.Context, a
return nil, err return nil, err
} }
dashboardFilePath := filepath.Join(plugin.PluginDir, cleanPath) file, err := openDashboardFile(plugin, cleanPath)
file, err := openDashboardFile(dashboardFilePath)
if err != nil { if err != nil {
return nil, err return nil, err
} }

@ -3,6 +3,7 @@ package dashboards
import ( import (
"context" "context"
"io" "io"
"io/fs"
"testing" "testing"
"testing/fstest" "testing/fstest"
@ -117,20 +118,24 @@ func TestDashboardFileStore(t *testing.T) {
t.Run("With filesystem", func(t *testing.T) { t.Run("With filesystem", func(t *testing.T) {
origOpenDashboardFile := openDashboardFile origOpenDashboardFile := openDashboardFile
mapFs := fstest.MapFS{ mapFs := fstest.MapFS{
"plugins/plugin-id/dashboards/dash1.json": { "dashboards/dash1.json": {
Data: []byte("dash1"), Data: []byte("dash1"),
}, },
"plugins/plugin-id/dashboards/dash2.json": { "dashboards/dash2.json": {
Data: []byte("dash2"), Data: []byte("dash2"),
}, },
"plugins/plugin-id/dashboards/dash3.json": { "dashboards/dash3.json": {
Data: []byte("dash3"), Data: []byte("dash3"),
}, },
"plugins/plugin-id/dash2.json": { "dash2.json": {
Data: []byte("dash2"), Data: []byte("dash2"),
}, },
} }
openDashboardFile = mapFs.Open openDashboardFile = func(p plugins.PluginDTO, name string) (fs.File, error) {
f, err := mapFs.Open(name)
require.NoError(t, err)
return f, nil
}
t.Cleanup(func() { t.Cleanup(func() {
openDashboardFile = origOpenDashboardFile openDashboardFile = origOpenDashboardFile
}) })
@ -156,7 +161,6 @@ func TestDashboardFileStore(t *testing.T) {
b, err := io.ReadAll(res.Content) b, err := io.ReadAll(res.Content)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "dash1", string(b)) require.Equal(t, "dash1", string(b))
require.NoError(t, res.Content.Close())
}) })
t.Run("Should return file content for dashboards/dash2.json", func(t *testing.T) { t.Run("Should return file content for dashboards/dash2.json", func(t *testing.T) {
@ -170,7 +174,6 @@ func TestDashboardFileStore(t *testing.T) {
b, err := io.ReadAll(res.Content) b, err := io.ReadAll(res.Content)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "dash2", string(b)) require.Equal(t, "dash2", string(b))
require.NoError(t, res.Content.Close())
}) })
t.Run("Should return error when trying to read relative file", func(t *testing.T) { t.Run("Should return error when trying to read relative file", func(t *testing.T) {
@ -189,39 +192,39 @@ func TestDashboardFileStore(t *testing.T) {
func setupPluginDashboardsForTest(t *testing.T) *FileStoreManager { func setupPluginDashboardsForTest(t *testing.T) *FileStoreManager {
t.Helper() t.Helper()
return &FileStoreManager{ p1 := &plugins.Plugin{
pluginStore: &plugins.FakePluginStore{ JSONData: plugins.JSONData{
PluginList: []plugins.PluginDTO{ ID: "pluginWithoutDashboards",
Includes: []*plugins.Includes{
{ {
JSONData: plugins.JSONData{ Type: "page",
ID: "pluginWithoutDashboards",
Includes: []*plugins.Includes{
{
Type: "page",
},
},
},
}, },
},
},
}
p2 := &plugins.Plugin{
JSONData: plugins.JSONData{
ID: "pluginWithDashboards",
Includes: []*plugins.Includes{
{ {
PluginDir: "plugins/plugin-id", Type: "page",
JSONData: plugins.JSONData{ },
ID: "pluginWithDashboards", {
Includes: []*plugins.Includes{ Type: "dashboard",
{ Path: "dashboards/dash1.json",
Type: "page", },
}, {
{ Type: "dashboard",
Type: "dashboard", Path: "dashboards/dash2.json",
Path: "dashboards/dash1.json",
},
{
Type: "dashboard",
Path: "dashboards/dash2.json",
},
},
},
}, },
}, },
}, },
} }
return &FileStoreManager{
pluginStore: &plugins.FakePluginStore{
PluginList: []plugins.PluginDTO{p1.ToDTO(), p2.ToDTO()},
},
}
} }

@ -51,8 +51,7 @@ func (m *PluginInstaller) Add(ctx context.Context, pluginID, version string, opt
if plugin.Info.Version == version { if plugin.Info.Version == version {
return plugins.DuplicateError{ return plugins.DuplicateError{
PluginID: plugin.ID, PluginID: plugin.ID,
ExistingPluginDir: plugin.PluginDir,
} }
} }
@ -65,8 +64,7 @@ func (m *PluginInstaller) Add(ctx context.Context, pluginID, version string, opt
// if existing plugin version is the same as the target update version // if existing plugin version is the same as the target update version
if dlOpts.Version == plugin.Info.Version { if dlOpts.Version == plugin.Info.Version {
return plugins.DuplicateError{ return plugins.DuplicateError{
PluginID: plugin.ID, PluginID: plugin.ID,
ExistingPluginDir: plugin.PluginDir,
} }
} }

@ -81,8 +81,7 @@ func TestPluginManager_Add_Remove(t *testing.T) {
err = inst.Add(context.Background(), pluginID, v1, plugins.CompatOpts{}) err = inst.Add(context.Background(), pluginID, v1, plugins.CompatOpts{})
require.Equal(t, plugins.DuplicateError{ require.Equal(t, plugins.DuplicateError{
PluginID: pluginV1.ID, PluginID: pluginV1.ID,
ExistingPluginDir: pluginV1.PluginDir,
}, err) }, err)
}) })

@ -127,7 +127,6 @@ func (l *Loader) loadPlugins(ctx context.Context, class plugins.Class, pluginJSO
plugin.Signature = sig.Status plugin.Signature = sig.Status
plugin.SignatureType = sig.Type plugin.SignatureType = sig.Type
plugin.SignatureOrg = sig.SigningOrg plugin.SignatureOrg = sig.SigningOrg
plugin.SignedFiles = sig.Files
loadedPlugins[plugin.PluginDir] = plugin loadedPlugins[plugin.PluginDir] = plugin
} }

@ -116,8 +116,8 @@ func TestIntegrationPluginManager(t *testing.T) {
ctx := context.Background() ctx := context.Background()
verifyCorePluginCatalogue(t, ctx, ps) verifyCorePluginCatalogue(t, ctx, ps)
verifyBundledPlugins(t, ctx, ps) verifyBundledPlugins(t, ctx, ps, reg)
verifyPluginStaticRoutes(t, ctx, ps) verifyPluginStaticRoutes(t, ctx, ps, reg)
verifyBackendProcesses(t, reg.Plugins(ctx)) verifyBackendProcesses(t, reg.Plugins(ctx))
verifyPluginQuery(t, ctx, client.ProvideService(reg, pCfg)) verifyPluginQuery(t, ctx, client.ProvideService(reg, pCfg))
} }
@ -245,7 +245,7 @@ func verifyCorePluginCatalogue(t *testing.T, ctx context.Context, ps *store.Serv
require.Equal(t, len(expPanels)+len(expDataSources)+len(expApps), len(ps.Plugins(ctx))) require.Equal(t, len(expPanels)+len(expDataSources)+len(expApps), len(ps.Plugins(ctx)))
} }
func verifyBundledPlugins(t *testing.T, ctx context.Context, ps *store.Service) { func verifyBundledPlugins(t *testing.T, ctx context.Context, ps *store.Service, reg registry.Service) {
t.Helper() t.Helper()
dsPlugins := make(map[string]struct{}) dsPlugins := make(map[string]struct{})
@ -258,6 +258,9 @@ func verifyBundledPlugins(t *testing.T, ctx context.Context, ps *store.Service)
require.NotEqual(t, plugins.PluginDTO{}, inputPlugin) require.NotEqual(t, plugins.PluginDTO{}, inputPlugin)
require.NotNil(t, dsPlugins["input"]) require.NotNil(t, dsPlugins["input"])
intInputPlugin, exists := reg.Plugin(ctx, "input")
require.True(t, exists)
pluginRoutes := make(map[string]*plugins.StaticRoute) pluginRoutes := make(map[string]*plugins.StaticRoute)
for _, r := range ps.Routes() { for _, r := range ps.Routes() {
pluginRoutes[r.PluginID] = r pluginRoutes[r.PluginID] = r
@ -265,23 +268,23 @@ func verifyBundledPlugins(t *testing.T, ctx context.Context, ps *store.Service)
for _, pluginID := range []string{"input"} { for _, pluginID := range []string{"input"} {
require.Contains(t, pluginRoutes, pluginID) require.Contains(t, pluginRoutes, pluginID)
require.True(t, strings.HasPrefix(pluginRoutes[pluginID].Directory, inputPlugin.PluginDir)) require.True(t, strings.HasPrefix(pluginRoutes[pluginID].Directory, intInputPlugin.PluginDir))
} }
} }
func verifyPluginStaticRoutes(t *testing.T, ctx context.Context, ps *store.Service) { func verifyPluginStaticRoutes(t *testing.T, ctx context.Context, rr plugins.StaticRouteResolver, reg registry.Service) {
routes := make(map[string]*plugins.StaticRoute) routes := make(map[string]*plugins.StaticRoute)
for _, route := range ps.Routes() { for _, route := range rr.Routes() {
routes[route.PluginID] = route routes[route.PluginID] = route
} }
require.Len(t, routes, 2) require.Len(t, routes, 2)
inputPlugin, _ := ps.Plugin(ctx, "input") inputPlugin, _ := reg.Plugin(ctx, "input")
require.NotNil(t, routes["input"]) require.NotNil(t, routes["input"])
require.Equal(t, routes["input"].Directory, inputPlugin.PluginDir) require.Equal(t, routes["input"].Directory, inputPlugin.PluginDir)
testAppPlugin, _ := ps.Plugin(ctx, "test-app") testAppPlugin, _ := reg.Plugin(ctx, "test-app")
require.Contains(t, routes, "test-app") require.Contains(t, routes, "test-app")
require.Equal(t, routes["test-app"].Directory, testAppPlugin.PluginDir) require.Equal(t, routes["test-app"].Directory, testAppPlugin.PluginDir)
} }

@ -111,12 +111,7 @@ func Calculate(mlog log.Logger, plugin *plugins.Plugin) (plugins.Signature, erro
}, err }, err
} }
manifestPath := filepath.Join(plugin.PluginDir, "MANIFEST.txt") byteValue := plugin.Manifest()
// nolint:gosec
// We can ignore the gosec G304 warning on this one because `manifestPath` is based
// on plugin the folder structure on disk and not user input.
byteValue, err := os.ReadFile(manifestPath)
if err != nil || len(byteValue) < 10 { if err != nil || len(byteValue) < 10 {
mlog.Debug("Plugin is unsigned", "id", plugin.ID) mlog.Debug("Plugin is unsigned", "id", plugin.ID)
return plugins.Signature{ return plugins.Signature{

@ -54,7 +54,7 @@ func (s *Validator) Validate(plugin *plugins.Plugin) *plugins.SignatureError {
SignatureStatus: plugins.SignatureUnsigned, SignatureStatus: plugins.SignatureUnsigned,
} }
} }
s.log.Warn("Permitting unsigned plugin. This is not recommended", "pluginID", plugin.ID, "pluginDir", plugin.PluginDir) s.log.Warn("Permitting unsigned plugin. This is not recommended", "pluginID", plugin.ID)
return nil return nil
case plugins.SignatureInvalid: case plugins.SignatureInvalid:
s.log.Debug("Plugin has an invalid signature", "pluginID", plugin.ID) s.log.Debug("Plugin has an invalid signature", "pluginID", plugin.ID)

@ -26,12 +26,11 @@ func (e NotFoundError) Error() string {
} }
type DuplicateError struct { type DuplicateError struct {
PluginID string PluginID string
ExistingPluginDir string
} }
func (e DuplicateError) Error() string { func (e DuplicateError) Error() string {
return fmt.Sprintf("plugin with ID '%s' already exists in '%s'", e.PluginID, e.ExistingPluginDir) return fmt.Sprintf("plugin with ID '%s' already exists", e.PluginID)
} }
func (e DuplicateError) Is(err error) bool { func (e DuplicateError) Is(err error) bool {
@ -195,13 +194,10 @@ func (s SignatureType) IsValid() bool {
return false return false
} }
type PluginFiles map[string]struct{}
type Signature struct { type Signature struct {
Status SignatureStatus Status SignatureStatus
Type SignatureType Type SignatureType
SigningOrg string SigningOrg string
Files PluginFiles
} }
type PluginMetaDTO struct { type PluginMetaDTO struct {

@ -4,6 +4,11 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/fs"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log"
@ -11,8 +16,11 @@ import (
"github.com/grafana/grafana/pkg/plugins/backendplugin/pluginextensionv2" "github.com/grafana/grafana/pkg/plugins/backendplugin/pluginextensionv2"
"github.com/grafana/grafana/pkg/plugins/backendplugin/secretsmanagerplugin" "github.com/grafana/grafana/pkg/plugins/backendplugin/secretsmanagerplugin"
"github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/util"
) )
var ErrFileNotExist = fmt.Errorf("file does not exist")
type Plugin struct { type Plugin struct {
JSONData JSONData
@ -30,7 +38,6 @@ type Plugin struct {
SignatureOrg string SignatureOrg string
Parent *Plugin Parent *Plugin
Children []*Plugin Children []*Plugin
SignedFiles PluginFiles
SignatureError *SignatureError SignatureError *SignatureError
// SystemJS fields // SystemJS fields
@ -46,8 +53,10 @@ type Plugin struct {
type PluginDTO struct { type PluginDTO struct {
JSONData JSONData
PluginDir string logger log.Logger
Class Class pluginDir string
Class Class
// App fields // App fields
IncludedInAppID string IncludedInAppID string
@ -58,7 +67,6 @@ type PluginDTO struct {
Signature SignatureStatus Signature SignatureStatus
SignatureType SignatureType SignatureType SignatureType
SignatureOrg string SignatureOrg string
SignedFiles PluginFiles
SignatureError *SignatureError SignatureError *SignatureError
// SystemJS fields // SystemJS fields
@ -89,21 +97,29 @@ func (p PluginDTO) IsSecretsManager() bool {
return p.JSONData.Type == SecretsManager return p.JSONData.Type == SecretsManager
} }
func (p PluginDTO) IncludedInSignature(file string) bool { func (p PluginDTO) File(name string) (fs.File, error) {
// permit Core plugin files cleanPath, err := util.CleanRelativePath(name)
if p.IsCorePlugin() { if err != nil {
return true // CleanRelativePath should clean and make the path relative so this is not expected to fail
return nil, err
} }
// permit when no signed files (no MANIFEST) absPluginDir, err := filepath.Abs(p.pluginDir)
if p.SignedFiles == nil { if err != nil {
return true return nil, err
} }
if _, exists := p.SignedFiles[file]; !exists { absFilePath := filepath.Join(absPluginDir, cleanPath)
return false // Wrapping in filepath.Clean to properly handle
// gosec G304 Potential file inclusion via variable rule.
f, err := os.Open(filepath.Clean(absFilePath))
if err != nil {
if os.IsNotExist(err) {
return nil, ErrFileNotExist
}
return nil, err
} }
return true return f, nil
} }
// JSONData represents the plugin's plugin.json // JSONData represents the plugin's plugin.json
@ -318,6 +334,25 @@ func (p *Plugin) Client() (PluginClient, bool) {
return nil, false return nil, false
} }
func (p *Plugin) ExecutablePath() string {
os := strings.ToLower(runtime.GOOS)
arch := runtime.GOARCH
extension := ""
if os == "windows" {
extension = ".exe"
}
if p.IsRenderer() {
return filepath.Join(p.PluginDir, fmt.Sprintf("%s_%s_%s%s", "plugin_start", os, strings.ToLower(arch), extension))
}
if p.IsSecretsManager() {
return filepath.Join(p.PluginDir, fmt.Sprintf("%s_%s_%s%s", "secrets_plugin_start", os, strings.ToLower(arch), extension))
}
return filepath.Join(p.PluginDir, fmt.Sprintf("%s_%s_%s%s", p.Executable, os, strings.ToLower(arch), extension))
}
type PluginClient interface { type PluginClient interface {
backend.QueryDataHandler backend.QueryDataHandler
backend.CollectMetricsHandler backend.CollectMetricsHandler
@ -330,8 +365,9 @@ func (p *Plugin) ToDTO() PluginDTO {
c, _ := p.Client() c, _ := p.Client()
return PluginDTO{ return PluginDTO{
logger: p.Logger(),
pluginDir: p.PluginDir,
JSONData: p.JSONData, JSONData: p.JSONData,
PluginDir: p.PluginDir,
Class: p.Class, Class: p.Class,
IncludedInAppID: p.IncludedInAppID, IncludedInAppID: p.IncludedInAppID,
DefaultNavURL: p.DefaultNavURL, DefaultNavURL: p.DefaultNavURL,
@ -339,7 +375,6 @@ func (p *Plugin) ToDTO() PluginDTO {
Signature: p.Signature, Signature: p.Signature,
SignatureType: p.SignatureType, SignatureType: p.SignatureType,
SignatureOrg: p.SignatureOrg, SignatureOrg: p.SignatureOrg,
SignedFiles: p.SignedFiles,
SignatureError: p.SignatureError, SignatureError: p.SignatureError,
Module: p.Module, Module: p.Module,
BaseURL: p.BaseURL, BaseURL: p.BaseURL,
@ -387,6 +422,15 @@ func (p *Plugin) IsExternalPlugin() bool {
return p.Class == External return p.Class == External
} }
func (p *Plugin) Manifest() []byte {
d, err := os.ReadFile(filepath.Join(p.PluginDir, "MANIFEST.txt"))
if err != nil {
return []byte{}
}
return d
}
type Class string type Class string
const ( const (

@ -116,7 +116,7 @@ func (s Service) LoadPluginDashboard(ctx context.Context, req *plugindashboards.
} }
defer func() { defer func() {
if err := resp.Content.Close(); err != nil { if err = resp.Content.Close(); err != nil {
s.logger.Warn("Failed to close plugin dashboard file", "reference", req.Reference, "err", err) s.logger.Warn("Failed to close plugin dashboard file", "reference", req.Reference, "err", err)
} }
}() }()

@ -191,9 +191,8 @@ func (m pluginDashboardStoreMock) ListPluginDashboardFiles(ctx context.Context,
func (m pluginDashboardStoreMock) GetPluginDashboardFileContents(ctx context.Context, args *dashboards.GetPluginDashboardFileContentsArgs) (*dashboards.GetPluginDashboardFileContentsResult, error) { func (m pluginDashboardStoreMock) GetPluginDashboardFileContents(ctx context.Context, args *dashboards.GetPluginDashboardFileContentsArgs) (*dashboards.GetPluginDashboardFileContentsResult, error) {
if dashboardFiles, exists := m.pluginDashboardFiles[args.PluginID]; exists { if dashboardFiles, exists := m.pluginDashboardFiles[args.PluginID]; exists {
if content, exists := dashboardFiles[args.FileReference]; exists { if content, exists := dashboardFiles[args.FileReference]; exists {
r := bytes.NewReader(content)
return &dashboards.GetPluginDashboardFileContentsResult{ return &dashboards.GetPluginDashboardFileContentsResult{
Content: io.NopCloser(r), Content: io.NopCloser(bytes.NewReader(content)),
}, nil }, nil
} }
} else if !exists { } else if !exists {

Loading…
Cancel
Save