Compare commits

...

4 Commits

Author SHA1 Message Date
Will Browne
e7146f39d2 fix lint issues 2026-01-12 14:01:54 +00:00
Will Browne
9443b42578 Merge branch 'main' into wb/module-hash 2026-01-12 14:00:11 +00:00
Will Browne
fcb76c0117 fix tests 2026-01-12 13:48:14 +00:00
Will Browne
be27b3295e add module hash field to plugin model 2026-01-12 11:40:20 +00:00
43 changed files with 767 additions and 620 deletions

View File

@@ -13,10 +13,9 @@ const (
)
// PluginAssetsCalculator is an interface for calculating plugin asset information.
// LocalProvider requires this to calculate loading strategy and module hash.
// LocalProvider requires this to calculate loading strategy.
type PluginAssetsCalculator interface {
LoadingStrategy(ctx context.Context, p pluginstore.Plugin) plugins.LoadingStrategy
ModuleHash(ctx context.Context, p pluginstore.Plugin) string
}
// LocalProvider retrieves plugin metadata for locally installed plugins.
@@ -27,7 +26,7 @@ type LocalProvider struct {
}
// NewLocalProvider creates a new LocalProvider for locally installed plugins.
// pluginAssets is required for calculating loading strategy and module hash.
// pluginAssets is required for calculating loading strategy.
func NewLocalProvider(pluginStore pluginstore.Store, pluginAssets PluginAssetsCalculator) *LocalProvider {
return &LocalProvider{
store: pluginStore,
@@ -43,7 +42,7 @@ func (p *LocalProvider) GetMeta(ctx context.Context, pluginID, version string) (
}
loadingStrategy := p.pluginAssets.LoadingStrategy(ctx, plugin)
moduleHash := p.pluginAssets.ModuleHash(ctx, plugin)
moduleHash := plugin.ModuleHash
spec := pluginStorePluginToMeta(plugin, loadingStrategy, moduleHash)
return &Result{

View File

@@ -161,7 +161,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
AliasIDs: panel.AliasIDs,
Info: panel.Info,
Module: panel.Module,
ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), panel),
ModuleHash: panel.ModuleHash,
BaseURL: panel.BaseURL,
SkipDataQuery: panel.SkipDataQuery,
Suggestions: panel.Suggestions,
@@ -527,7 +527,7 @@ func (hs *HTTPServer) getFSDataSources(c *contextmodel.ReqContext, availablePlug
JSONData: plugin.JSONData,
Signature: plugin.Signature,
Module: plugin.Module,
ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), plugin),
ModuleHash: plugin.ModuleHash,
BaseURL: plugin.BaseURL,
Angular: plugin.Angular,
MultiValueFilterOperators: plugin.MultiValueFilterOperators,
@@ -641,7 +641,7 @@ func (hs *HTTPServer) newAppDTO(ctx context.Context, plugin pluginstore.Plugin,
LoadingStrategy: hs.pluginAssets.LoadingStrategy(ctx, plugin),
Extensions: plugin.Extensions,
Dependencies: plugin.Dependencies,
ModuleHash: hs.pluginAssets.ModuleHash(ctx, plugin),
ModuleHash: plugin.ModuleHash,
Translations: plugin.Translations,
BuildMode: plugin.BuildMode,
}

View File

@@ -20,8 +20,6 @@ import (
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/manager/pluginfakes"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
@@ -79,8 +77,7 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.F
var pluginsAssets = passets
if pluginsAssets == nil {
sig := signature.ProvideService(pluginsCfg, statickey.New())
pluginsAssets = pluginassets.ProvideService(pluginsCfg, pluginsCDN, sig, pluginStore)
pluginsAssets = pluginassets.ProvideService(pluginsCfg, pluginsCDN, pluginStore)
}
hs := &HTTPServer{
@@ -714,6 +711,6 @@ func newPluginAssets() func() *pluginassets.Service {
func newPluginAssetsWithConfig(pCfg *config.PluginManagementCfg) func() *pluginassets.Service {
return func() *pluginassets.Service {
return pluginassets.ProvideService(pCfg, pluginscdn.ProvideService(pCfg), signature.ProvideService(pCfg, statickey.New()), &pluginstore.FakePluginStore{})
return pluginassets.ProvideService(pCfg, pluginscdn.ProvideService(pCfg), &pluginstore.FakePluginStore{})
}
}

View File

@@ -201,7 +201,7 @@ func (hs *HTTPServer) GetPluginSettingByID(c *contextmodel.ReqContext) response.
Includes: plugin.Includes,
BaseUrl: plugin.BaseURL,
Module: plugin.Module,
ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), plugin),
ModuleHash: plugin.ModuleHash,
DefaultNavUrl: path.Join(hs.Cfg.AppSubURL, plugin.DefaultNavURL),
State: plugin.State,
Signature: plugin.Signature,

View File

@@ -28,8 +28,6 @@ import (
"github.com/grafana/grafana/pkg/plugins/manager/filestore"
"github.com/grafana/grafana/pkg/plugins/manager/pluginfakes"
"github.com/grafana/grafana/pkg/plugins/manager/registry"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
"github.com/grafana/grafana/pkg/plugins/pluginerrs"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
@@ -848,8 +846,7 @@ func Test_PluginsSettings(t *testing.T) {
}
pCfg := &config.PluginManagementCfg{}
pluginCDN := pluginscdn.ProvideService(pCfg)
sig := signature.ProvideService(pCfg, statickey.New())
hs.pluginAssets = pluginassets.ProvideService(pCfg, pluginCDN, sig, hs.pluginStore)
hs.pluginAssets = pluginassets.ProvideService(pCfg, pluginCDN, hs.pluginStore)
hs.pluginErrorResolver = pluginerrs.ProvideStore(errTracker)
hs.pluginsUpdateChecker, err = updatemanager.ProvidePluginsService(
hs.Cfg,

View File

@@ -140,7 +140,9 @@ type Licensing interface {
}
type SignatureCalculator interface {
Calculate(ctx context.Context, src PluginSource, plugin FoundPlugin) (Signature, error)
// Calculate calculates the signature and returns both the signature and the manifest.
// The manifest may be nil if the plugin is unsigned or if an error occurred.
Calculate(ctx context.Context, src PluginSource, plugin FoundPlugin) (Signature, *PluginManifest, error)
}
type KeyStore interface {

View File

@@ -216,10 +216,27 @@ func TestLoader_Load(t *testing.T) {
ExtensionPoints: []plugins.ExtensionPoint{},
},
},
Class: plugins.ClassExternal,
Module: "public/plugins/test-app/module.js",
BaseURL: "public/plugins/test-app",
FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/includes-symlinks")),
Class: plugins.ClassExternal,
Module: "public/plugins/test-app/module.js",
BaseURL: "public/plugins/test-app",
FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/includes-symlinks")),
Manifest: &plugins.PluginManifest{
Plugin: "test-app",
Version: "1.0.0",
KeyID: "7e4d0c6a708866e7",
Time: 1622547655175,
Files: map[string]string{
"dashboards/connections.json": "bea86da4be970b98dc4681802ab55cdef3441dc3eb3c654cb207948d17b25303",
"dashboards/extra/memory.json": "7c042464941084caa91d0a9a2f188b05315a9796308a652ccdee31ca4fbcbfee",
"plugin.json": "c59a51bf6d7ecd7a99608ccb99353390c8b973672a938a0247164324005c0caf",
"symlink_to_txt": "9f32c171bf78a85d5cb77a48ab44f85578ee2942a1fc9f9ec4fde194ae4ff048",
"text.txt": "9f32c171bf78a85d5cb77a48ab44f85578ee2942a1fc9f9ec4fde194ae4ff048",
},
ManifestVersion: "2.0.0",
SignatureType: plugins.SignatureTypeGrafana,
SignedByOrg: "grafana",
SignedByOrgName: "Grafana Labs",
},
Signature: "valid",
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",

View File

@@ -11,6 +11,7 @@ import (
"github.com/grafana/grafana/pkg/plugins/log"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/pluginassets"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
"github.com/grafana/grafana/pkg/plugins/tracing"
"github.com/grafana/grafana/pkg/semconv"
)
@@ -54,7 +55,7 @@ func New(cfg *config.PluginManagementCfg, opts Opts) *Bootstrap {
}
if opts.DecorateFuncs == nil {
opts.DecorateFuncs = DefaultDecorateFuncs(cfg)
opts.DecorateFuncs = DefaultDecorateFuncs(cfg, pluginscdn.ProvideService(cfg))
}
return &Bootstrap{

View File

@@ -11,6 +11,7 @@ import (
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/log"
"github.com/grafana/grafana/pkg/plugins/pluginassets"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
)
// DefaultConstructor implements the default ConstructFunc used for the Construct step of the Bootstrap stage.
@@ -28,12 +29,13 @@ func DefaultConstructFunc(cfg *config.PluginManagementCfg, signatureCalculator p
}
// DefaultDecorateFuncs are the default DecorateFuncs used for the Decorate step of the Bootstrap stage.
func DefaultDecorateFuncs(cfg *config.PluginManagementCfg) []DecorateFunc {
func DefaultDecorateFuncs(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service) []DecorateFunc {
return []DecorateFunc{
AppDefaultNavURLDecorateFunc,
TemplateDecorateFunc,
AppChildDecorateFunc(),
SkipHostEnvVarsDecorateFunc(cfg),
ModuleHashDecorateFunc(cfg, cdn),
}
}
@@ -48,19 +50,30 @@ func NewDefaultConstructor(cfg *config.PluginManagementCfg, signatureCalculator
// Construct will calculate the plugin's signature state and create the plugin using the pluginFactoryFunc.
func (c *DefaultConstructor) Construct(ctx context.Context, src plugins.PluginSource, bundle *plugins.FoundBundle) ([]*plugins.Plugin, error) {
sig, err := c.signatureCalculator.Calculate(ctx, src, bundle.Primary)
// Calculate signature and cache manifest
sig, manifest, err := c.signatureCalculator.Calculate(ctx, src, bundle.Primary)
if err != nil {
c.log.Warn("Could not calculate plugin signature state", "pluginId", bundle.Primary.JSONData.ID, "error", err)
return nil, err
}
plugin, err := c.pluginFactoryFunc(bundle, src.PluginClass(ctx), sig)
if err != nil {
c.log.Error("Could not create primary plugin base", "pluginId", bundle.Primary.JSONData.ID, "error", err)
return nil, err
}
plugin.Manifest = manifest
res := make([]*plugins.Plugin, 0, len(plugin.Children)+1)
res = append(res, plugin)
res = append(res, plugin.Children...)
for _, child := range plugin.Children {
// Child plugins use the parent's manifest
if child.Parent != nil && child.Parent.Manifest != nil {
child.Manifest = child.Parent.Manifest
}
res = append(res, child)
}
return res, nil
}
@@ -145,3 +158,11 @@ func SkipHostEnvVarsDecorateFunc(cfg *config.PluginManagementCfg) DecorateFunc {
return p, nil
}
}
// ModuleHashDecorateFunc returns a DecorateFunc that calculates and sets the module hash for the plugin.
func ModuleHashDecorateFunc(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service) DecorateFunc {
return func(_ context.Context, p *plugins.Plugin) (*plugins.Plugin, error) {
p.ModuleHash = pluginassets.CalculateModuleHash(p, cfg, cdn)
return p, nil
}
}

View File

@@ -14,7 +14,6 @@ import (
"path"
"path/filepath"
"runtime"
"strings"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/clearsign"
@@ -37,26 +36,6 @@ var (
fromSlash = filepath.FromSlash
)
// PluginManifest holds details for the file manifest
type PluginManifest struct {
Plugin string `json:"plugin"`
Version string `json:"version"`
KeyID string `json:"keyId"`
Time int64 `json:"time"`
Files map[string]string `json:"files"`
// V2 supported fields
ManifestVersion string `json:"manifestVersion"`
SignatureType plugins.SignatureType `json:"signatureType"`
SignedByOrg string `json:"signedByOrg"`
SignedByOrgName string `json:"signedByOrgName"`
RootURLs []string `json:"rootUrls"`
}
func (m *PluginManifest) IsV2() bool {
return strings.HasPrefix(m.ManifestVersion, "2.")
}
type Signature struct {
kr plugins.KeyRetriever
cfg *config.PluginManagementCfg
@@ -87,14 +66,14 @@ func DefaultCalculator(cfg *config.PluginManagementCfg) *Signature {
// readPluginManifest attempts to read and verify the plugin manifest
// if any error occurs or the manifest is not valid, this will return an error
func (s *Signature) readPluginManifest(ctx context.Context, body []byte) (*PluginManifest, error) {
func (s *Signature) readPluginManifest(ctx context.Context, body []byte) (*plugins.PluginManifest, error) {
block, _ := clearsign.Decode(body)
if block == nil {
return nil, errors.New("unable to decode manifest")
}
// Convert to a well typed object
var manifest PluginManifest
var manifest plugins.PluginManifest
err := json.Unmarshal(block.Plaintext, &manifest)
if err != nil {
return nil, fmt.Errorf("%v: %w", "Error parsing manifest JSON", err)
@@ -111,7 +90,7 @@ var ErrSignatureTypeUnsigned = errors.New("plugin is unsigned")
// ReadPluginManifestFromFS reads the plugin manifest from the provided plugins.FS.
// If the manifest is not found, it will return an error wrapping ErrSignatureTypeUnsigned.
func (s *Signature) ReadPluginManifestFromFS(ctx context.Context, pfs plugins.FS) (*PluginManifest, error) {
func (s *Signature) ReadPluginManifestFromFS(ctx context.Context, pfs plugins.FS) (*plugins.PluginManifest, error) {
f, err := pfs.Open("MANIFEST.txt")
if err != nil {
if errors.Is(err, plugins.ErrFileNotExist) {
@@ -140,9 +119,9 @@ func (s *Signature) ReadPluginManifestFromFS(ctx context.Context, pfs plugins.FS
return manifest, nil
}
func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plugin plugins.FoundPlugin) (plugins.Signature, error) {
func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plugin plugins.FoundPlugin) (plugins.Signature, *plugins.PluginManifest, error) {
if defaultSignature, exists := src.DefaultSignature(ctx, plugin.JSONData.ID); exists {
return defaultSignature, nil
return defaultSignature, nil, nil
}
manifest, err := s.ReadPluginManifestFromFS(ctx, plugin.FS)
@@ -151,29 +130,29 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu
s.log.Warn("Plugin is unsigned", "id", plugin.JSONData.ID, "err", err)
return plugins.Signature{
Status: plugins.SignatureStatusUnsigned,
}, nil
}, nil, nil
case err != nil:
s.log.Warn("Plugin signature is invalid", "id", plugin.JSONData.ID, "err", err)
return plugins.Signature{
Status: plugins.SignatureStatusInvalid,
}, nil
}, nil, nil
}
if !manifest.IsV2() {
return plugins.Signature{
Status: plugins.SignatureStatusInvalid,
}, nil
}, nil, nil
}
fsFiles, err := plugin.FS.Files()
if err != nil {
return plugins.Signature{}, fmt.Errorf("files: %w", err)
return plugins.Signature{}, nil, fmt.Errorf("files: %w", err)
}
if len(fsFiles) == 0 {
s.log.Warn("No plugin file information in directory", "pluginId", plugin.JSONData.ID)
return plugins.Signature{
Status: plugins.SignatureStatusInvalid,
}, nil
}, nil, nil
}
// Make sure the versions all match
@@ -181,20 +160,20 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu
s.log.Debug("Plugin signature invalid because ID or Version mismatch", "pluginId", plugin.JSONData.ID, "manifestPluginId", manifest.Plugin, "pluginVersion", plugin.JSONData.Info.Version, "manifestPluginVersion", manifest.Version)
return plugins.Signature{
Status: plugins.SignatureStatusModified,
}, nil
}, nil, nil
}
// Validate that plugin is running within defined root URLs
if len(manifest.RootURLs) > 0 {
if match, err := urlMatch(manifest.RootURLs, s.cfg.GrafanaAppURL, manifest.SignatureType); err != nil {
s.log.Warn("Could not verify if root URLs match", "plugin", plugin.JSONData.ID, "rootUrls", manifest.RootURLs)
return plugins.Signature{}, err
return plugins.Signature{}, nil, err
} else if !match {
s.log.Warn("Could not find root URL that matches running application URL", "plugin", plugin.JSONData.ID,
"appUrl", s.cfg.GrafanaAppURL, "rootUrls", manifest.RootURLs)
return plugins.Signature{
Status: plugins.SignatureStatusInvalid,
}, nil
}, nil, nil
}
}
@@ -207,7 +186,7 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu
s.log.Debug("Plugin signature invalid", "pluginId", plugin.JSONData.ID, "error", err)
return plugins.Signature{
Status: plugins.SignatureStatusModified,
}, nil
}, nil, nil
}
manifestFiles[p] = struct{}{}
@@ -236,7 +215,7 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu
s.log.Warn("The following files were not included in the signature", "plugin", plugin.JSONData.ID, "files", unsignedFiles)
return plugins.Signature{
Status: plugins.SignatureStatusModified,
}, nil
}, nil, nil
}
s.log.Debug("Plugin signature valid", "id", plugin.JSONData.ID)
@@ -244,7 +223,7 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu
Status: plugins.SignatureStatusValid,
Type: manifest.SignatureType,
SigningOrg: manifest.SignedByOrgName,
}, nil
}, manifest, nil
}
func verifyHash(mlog log.Logger, plugin plugins.FoundPlugin, path, hash string) error {
@@ -321,7 +300,7 @@ func (r invalidFieldErr) Error() string {
return fmt.Sprintf("valid manifest field %s is required", r.field)
}
func (s *Signature) validateManifest(ctx context.Context, m PluginManifest, block *clearsign.Block) error {
func (s *Signature) validateManifest(ctx context.Context, m plugins.PluginManifest, block *clearsign.Block) error {
if len(m.Plugin) == 0 {
return invalidFieldErr{field: "plugin"}
}

View File

@@ -164,7 +164,7 @@ func TestCalculate(t *testing.T) {
for _, tc := range tcs {
basePath := filepath.Join(parentDir, "testdata/non-pvt-with-root-url/plugin")
s := provideTestServiceWithConfig(&config.PluginManagementCfg{GrafanaAppURL: tc.appURL})
sig, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{
sig, _, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{
PluginClassFunc: func(ctx context.Context) plugins.Class {
return plugins.ClassExternal
},
@@ -192,7 +192,7 @@ func TestCalculate(t *testing.T) {
runningWindows = true
s := provideDefaultTestService()
sig, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{
sig, _, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{
PluginClassFunc: func(ctx context.Context) plugins.Class {
return plugins.ClassExternal
},
@@ -260,7 +260,7 @@ func TestCalculate(t *testing.T) {
require.NoError(t, err)
pfs, err = newPathSeparatorOverrideFS(string(tc.platform.separator), pfs)
require.NoError(t, err)
sig, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{
sig, _, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{
PluginClassFunc: func(ctx context.Context) plugins.Class {
return plugins.ClassExternal
},
@@ -396,7 +396,7 @@ func TestFSPathSeparatorFiles(t *testing.T) {
}
}
func fileList(manifest *PluginManifest) []string {
func fileList(manifest *plugins.PluginManifest) []string {
keys := make([]string, 0, len(manifest.Files))
for k := range manifest.Files {
keys = append(keys, k)
@@ -682,52 +682,52 @@ func Test_urlMatch_private(t *testing.T) {
func Test_validateManifest(t *testing.T) {
tcs := []struct {
name string
manifest *PluginManifest
manifest *plugins.PluginManifest
expectedErr string
}{
{
name: "Empty plugin field",
manifest: createV2Manifest(t, func(m *PluginManifest) { m.Plugin = "" }),
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.Plugin = "" }),
expectedErr: "valid manifest field plugin is required",
},
{
name: "Empty keyId field",
manifest: createV2Manifest(t, func(m *PluginManifest) { m.KeyID = "" }),
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.KeyID = "" }),
expectedErr: "valid manifest field keyId is required",
},
{
name: "Empty signedByOrg field",
manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignedByOrg = "" }),
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.SignedByOrg = "" }),
expectedErr: "valid manifest field signedByOrg is required",
},
{
name: "Empty signedByOrgName field",
manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignedByOrgName = "" }),
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.SignedByOrgName = "" }),
expectedErr: "valid manifest field SignedByOrgName is required",
},
{
name: "Empty signatureType field",
manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignatureType = "" }),
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.SignatureType = "" }),
expectedErr: "valid manifest field signatureType is required",
},
{
name: "Invalid signatureType field",
manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignatureType = "invalidSignatureType" }),
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.SignatureType = "invalidSignatureType" }),
expectedErr: "valid manifest field signatureType is required",
},
{
name: "Empty files field",
manifest: createV2Manifest(t, func(m *PluginManifest) { m.Files = map[string]string{} }),
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.Files = map[string]string{} }),
expectedErr: "valid manifest field files is required",
},
{
name: "Empty time field",
manifest: createV2Manifest(t, func(m *PluginManifest) { m.Time = 0 }),
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.Time = 0 }),
expectedErr: "valid manifest field time is required",
},
{
name: "Empty version field",
manifest: createV2Manifest(t, func(m *PluginManifest) { m.Version = "" }),
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.Version = "" }),
expectedErr: "valid manifest field version is required",
},
}
@@ -740,10 +740,10 @@ func Test_validateManifest(t *testing.T) {
}
}
func createV2Manifest(t *testing.T, cbs ...func(*PluginManifest)) *PluginManifest {
func createV2Manifest(t *testing.T, cbs ...func(*plugins.PluginManifest)) *plugins.PluginManifest {
t.Helper()
m := &PluginManifest{
m := &plugins.PluginManifest{
Plugin: "grafana-test-app",
Version: "2.5.3",
KeyID: "7e4d0c6a708866e7",

View File

@@ -0,0 +1,90 @@
package pluginassets
import (
"encoding/base64"
"encoding/hex"
"path"
"path/filepath"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
)
// CalculateModuleHash calculates the module.js SHA256 hash for a plugin in the format expected by the browser for SRI checks.
// The module hash is read from the plugin's cached manifest.
// For nested plugins, the module hash is read from the root parent plugin's manifest.
// If the plugin is unsigned or not a CDN plugin, an empty string is returned.
func CalculateModuleHash(p *plugins.Plugin, cfg *config.PluginManagementCfg, cdn *pluginscdn.Service) string {
if cfg == nil || !cfg.Features.SriChecksEnabled {
return ""
}
if !p.Signature.IsValid() {
return ""
}
rootParent := findRootParent(p)
if rootParent.Manifest == nil {
return ""
}
if !rootParent.Manifest.IsV2() {
return ""
}
if !cdnEnabled(rootParent, cdn) {
return ""
}
modulePath := getModulePathInManifest(p, rootParent)
moduleHash, ok := rootParent.Manifest.Files[modulePath]
if !ok {
return ""
}
return convertHashForSRI(moduleHash)
}
// findRootParent returns the root parent plugin (the one that contains the manifest).
// For non-nested plugins, it returns the plugin itself.
func findRootParent(p *plugins.Plugin) *plugins.Plugin {
root := p
for root.Parent != nil {
root = root.Parent
}
return root
}
// getModulePathInManifest returns the path to module.js as it appears in the manifest.
// For nested plugins, this is the relative path from the root parent to the plugin's module.js.
// For non-nested plugins, this is simply "module.js".
func getModulePathInManifest(p *plugins.Plugin, rootParent *plugins.Plugin) string {
if p == rootParent {
return "module.js"
}
// Calculate the relative path from root parent to this plugin
relPath, err := rootParent.FS.Rel(p.FS.Base())
if err != nil {
return ""
}
// MANIFEST.txt uses forward slashes as path separators
pluginRootPath := filepath.ToSlash(relPath)
return path.Join(pluginRootPath, "module.js")
}
// convertHashForSRI takes a SHA256 hash string and returns it as expected by the browser for SRI checks.
func convertHashForSRI(h string) string {
hb, err := hex.DecodeString(h)
if err != nil {
return ""
}
return "sha256-" + base64.StdEncoding.EncodeToString(hb)
}
// cdnEnabled checks if a plugin is loaded via CDN
func cdnEnabled(p *plugins.Plugin, cdn *pluginscdn.Service) bool {
return p.FS.Type().CDN() || cdn.PluginSupported(p.ID)
}

View File

@@ -0,0 +1,356 @@
package pluginassets
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
)
func TestConvertHashForSRI(t *testing.T) {
for _, tc := range []struct {
hash string
expHash string
expErr bool
}{
{
hash: "ddfcb449445064e6c39f0c20b15be3cb6a55837cf4781df23d02de005f436811",
expHash: "sha256-3fy0SURQZObDnwwgsVvjy2pVg3z0eB3yPQLeAF9DaBE=",
},
{
hash: "not-a-valid-hash",
expErr: true,
},
} {
t.Run(tc.hash, func(t *testing.T) {
r := convertHashForSRI(tc.hash)
if tc.expErr {
// convertHashForSRI returns empty string on error
require.Empty(t, r)
} else {
require.Equal(t, tc.expHash, r)
}
})
}
}
func TestCalculateModuleHash(t *testing.T) {
const (
pluginID = "grafana-test-datasource"
parentPluginID = "grafana-test-app"
)
// Helper to create a plugin with manifest
createPluginWithManifest := func(id string, manifest *plugins.PluginManifest, parent *plugins.Plugin) *plugins.Plugin {
p := &plugins.Plugin{
JSONData: plugins.JSONData{
ID: id,
},
Signature: plugins.SignatureStatusValid,
Manifest: manifest,
}
if parent != nil {
p.Parent = parent
}
return p
}
// Helper to create a v2 manifest
createV2Manifest := func(files map[string]string) *plugins.PluginManifest {
return &plugins.PluginManifest{
ManifestVersion: "2.0.0",
Files: files,
}
}
for _, tc := range []struct {
name string
plugin *plugins.Plugin
cfg *config.PluginManagementCfg
cdn *pluginscdn.Service
expModuleHash string
}{
{
name: "should return empty string when cfg is nil",
plugin: createPluginWithManifest(pluginID, createV2Manifest(map[string]string{
"module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03",
}), nil),
cfg: nil,
cdn: nil,
expModuleHash: "",
},
{
name: "should return empty string when SRI checks are disabled",
plugin: createPluginWithManifest(pluginID, createV2Manifest(map[string]string{
"module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03",
}), nil),
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: false}},
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
expModuleHash: "",
},
{
name: "should return empty string for unsigned plugin",
plugin: &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusUnsigned,
Manifest: createV2Manifest(map[string]string{"module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"}),
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")),
},
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}},
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
expModuleHash: "",
},
{
name: "should return module hash for valid plugin",
plugin: &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusValid,
Manifest: createV2Manifest(map[string]string{"module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"}),
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")),
},
cfg: &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.example.com",
Features: config.Features{SriChecksEnabled: true},
PluginSettings: config.PluginSettings{
pluginID: {"cdn": "true"},
},
},
cdn: func() *pluginscdn.Service {
cfg := &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.example.com",
PluginSettings: config.PluginSettings{
pluginID: {"cdn": "true"},
},
}
return pluginscdn.ProvideService(cfg)
}(),
expModuleHash: "sha256-WJG1tSLV3whtD/CxEPvZ0hu0/HFjrzTQgoai6Eb2vgM=",
},
{
name: "should return empty string when manifest is nil",
plugin: &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusValid,
Manifest: nil,
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")),
},
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}},
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
expModuleHash: "",
},
{
name: "should return empty string for v1 manifest",
plugin: &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusValid,
Manifest: &plugins.PluginManifest{
ManifestVersion: "1.0.0",
Files: map[string]string{"module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"},
},
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")),
},
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}},
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
expModuleHash: "",
},
{
name: "should return empty string when module.js is not in manifest",
plugin: &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusValid,
Manifest: createV2Manifest(map[string]string{"plugin.json": "129fab4e0584d18c778ebdfa5fe1a68edf2e5c5aeb8290b2c68182c857cb59f8"}),
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")),
},
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}},
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
expModuleHash: "",
},
{
name: "missing module.js entry from MANIFEST.txt should not return module hash",
plugin: &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusValid,
Manifest: createV2Manifest(map[string]string{"plugin.json": "129fab4e0584d18c778ebdfa5fe1a68edf2e5c5aeb8290b2c68182c857cb59f8"}),
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-module-js")),
},
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}},
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
expModuleHash: "",
},
{
name: "signed status but missing MANIFEST.txt should not return module hash",
plugin: &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusValid,
Manifest: nil,
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-manifest-txt")),
},
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}},
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
expModuleHash: "",
},
{
// parentPluginID (/)
// └── pluginID (/datasource)
name: "nested plugin should return module hash from parent MANIFEST.txt",
plugin: func() *plugins.Plugin {
parent := &plugins.Plugin{
JSONData: plugins.JSONData{ID: parentPluginID},
Signature: plugins.SignatureStatusValid,
Manifest: createV2Manifest(map[string]string{
"module.js": "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a",
"datasource/module.js": "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711",
}),
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested")),
}
return &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusValid,
Parent: parent,
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "datasource")),
}
}(),
cfg: &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.example.com",
Features: config.Features{SriChecksEnabled: true},
PluginSettings: config.PluginSettings{
pluginID: {"cdn": "true"},
parentPluginID: {"cdn": "true"},
},
},
cdn: func() *pluginscdn.Service {
cfg := &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.example.com",
PluginSettings: config.PluginSettings{
pluginID: {"cdn": "true"},
parentPluginID: {"cdn": "true"},
},
}
return pluginscdn.ProvideService(cfg)
}(),
expModuleHash: "sha256-BNcNsJHZbEd1+zK6Wo+EzCKJPrQ6/bZJcmZh1EJcZxE=",
},
{
// parentPluginID (/)
// └── pluginID (/panels/one)
name: "nested plugin deeper than one subfolder should return module hash from parent MANIFEST.txt",
plugin: func() *plugins.Plugin {
parent := &plugins.Plugin{
JSONData: plugins.JSONData{ID: parentPluginID},
Signature: plugins.SignatureStatusValid,
Manifest: createV2Manifest(map[string]string{
"module.js": "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a",
"panels/one/module.js": "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f",
"datasource/module.js": "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711",
}),
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested")),
}
return &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusValid,
Parent: parent,
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one")),
}
}(),
cfg: &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.example.com",
Features: config.Features{SriChecksEnabled: true},
PluginSettings: config.PluginSettings{
pluginID: {"cdn": "true"},
parentPluginID: {"cdn": "true"},
},
},
cdn: func() *pluginscdn.Service {
cfg := &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.example.com",
PluginSettings: config.PluginSettings{
pluginID: {"cdn": "true"},
parentPluginID: {"cdn": "true"},
},
}
return pluginscdn.ProvideService(cfg)
}(),
expModuleHash: "sha256-y9GsIoRkWg4emocipyn1vN0rgxIicocJxjYL7s3WFD8=",
},
{
// grand-parent-app (/)
// ├── parent-datasource (/datasource)
// │ └── child-panel (/datasource/panels/one)
name: "nested plugin of a nested plugin should return module hash from grandparent MANIFEST.txt",
plugin: func() *plugins.Plugin {
grandparent := &plugins.Plugin{
JSONData: plugins.JSONData{ID: "grand-parent-app"},
Signature: plugins.SignatureStatusValid,
Manifest: createV2Manifest(map[string]string{
"module.js": "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a",
"datasource/module.js": "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711",
"datasource/panels/one/module.js": "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f",
}),
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested")),
}
parent := &plugins.Plugin{
JSONData: plugins.JSONData{ID: "parent-datasource"},
Signature: plugins.SignatureStatusValid,
Parent: grandparent,
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource")),
}
return &plugins.Plugin{
JSONData: plugins.JSONData{ID: "child-panel"},
Signature: plugins.SignatureStatusValid,
Parent: parent,
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource", "panels", "one")),
}
}(),
cfg: &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.example.com",
Features: config.Features{SriChecksEnabled: true},
PluginSettings: config.PluginSettings{
"child-panel": {"cdn": "true"},
"parent-datasource": {"cdn": "true"},
"grand-parent-app": {"cdn": "true"},
},
},
cdn: func() *pluginscdn.Service {
cfg := &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.example.com",
PluginSettings: config.PluginSettings{
"child-panel": {"cdn": "true"},
"parent-datasource": {"cdn": "true"},
"grand-parent-app": {"cdn": "true"},
},
}
return pluginscdn.ProvideService(cfg)
}(),
expModuleHash: "sha256-y9GsIoRkWg4emocipyn1vN0rgxIicocJxjYL7s3WFD8=",
},
{
name: "nested plugin should not return module hash when parent manifest is nil",
plugin: func() *plugins.Plugin {
parent := &plugins.Plugin{
JSONData: plugins.JSONData{ID: parentPluginID},
Signature: plugins.SignatureStatusValid,
Manifest: nil, // Parent has no manifest
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested")),
}
return &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusValid,
Parent: parent,
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one")),
}
}(),
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}},
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
expModuleHash: "",
},
} {
t.Run(tc.name, func(t *testing.T) {
result := CalculateModuleHash(tc.plugin, tc.cfg, tc.cdn)
require.Equal(t, tc.expModuleHash, result)
})
}
}

View File

@@ -40,6 +40,7 @@ type Plugin struct {
Pinned bool
// Signature fields
Manifest *PluginManifest
Signature SignatureStatus
SignatureType SignatureType
SignatureOrg string
@@ -48,8 +49,9 @@ type Plugin struct {
Error *Error
// SystemJS fields
Module string
BaseURL string
Module string
ModuleHash string
BaseURL string
Angular AngularMeta
@@ -532,3 +534,24 @@ func (pt Type) IsValid() bool {
}
return false
}
// PluginManifest holds details for the file manifest
type PluginManifest struct {
Plugin string `json:"plugin"`
Version string `json:"version"`
KeyID string `json:"keyId"`
Time int64 `json:"time"`
Files map[string]string `json:"files"`
// V2 supported fields
ManifestVersion string `json:"manifestVersion"`
SignatureType SignatureType `json:"signatureType"`
SignedByOrg string `json:"signedByOrg"`
SignedByOrgName string `json:"signedByOrgName"`
RootURLs []string `json:"rootUrls"`
}
// IsV2 returns true if the manifest is version 2.x
func (m *PluginManifest) IsV2() bool {
return strings.HasPrefix(m.ManifestVersion, "2.")
}

12
pkg/server/wire_gen.go generated
View File

@@ -375,7 +375,8 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
keyretrieverService := keyretriever.ProvideService(keyRetriever)
signatureSignature := signature.ProvideService(pluginManagementCfg, keyretrieverService)
localProvider := pluginassets.NewLocalProvider()
bootstrap := pipeline.ProvideBootstrapStage(pluginManagementCfg, signatureSignature, localProvider)
pluginscdnService := pluginscdn.ProvideService(pluginManagementCfg)
bootstrap := pipeline.ProvideBootstrapStage(pluginManagementCfg, signatureSignature, localProvider, pluginscdnService)
unsignedPluginAuthorizer := signature.ProvideOSSAuthorizer(pluginManagementCfg)
validation := signature.ProvideValidatorService(unsignedPluginAuthorizer)
angularpatternsstoreService := angularpatternsstore.ProvideService(kvStore)
@@ -713,8 +714,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
if err != nil {
return nil, err
}
pluginscdnService := pluginscdn.ProvideService(pluginManagementCfg)
pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, signatureSignature, pluginstoreService)
pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, pluginstoreService)
avatarCacheServer := avatar.ProvideAvatarCacheServer(cfg)
prefService := prefimpl.ProvideService(sqlStore, cfg)
dashboardPermissionsService, err := ossaccesscontrol.ProvideDashboardPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, dashboardService, folderimplService, acimplService, teamService, userService, actionSetService, dashboardServiceImpl, eventualRestConfigProvider)
@@ -1035,7 +1035,8 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
keyretrieverService := keyretriever.ProvideService(keyRetriever)
signatureSignature := signature.ProvideService(pluginManagementCfg, keyretrieverService)
localProvider := pluginassets.NewLocalProvider()
bootstrap := pipeline.ProvideBootstrapStage(pluginManagementCfg, signatureSignature, localProvider)
pluginscdnService := pluginscdn.ProvideService(pluginManagementCfg)
bootstrap := pipeline.ProvideBootstrapStage(pluginManagementCfg, signatureSignature, localProvider, pluginscdnService)
unsignedPluginAuthorizer := signature.ProvideOSSAuthorizer(pluginManagementCfg)
validation := signature.ProvideValidatorService(unsignedPluginAuthorizer)
angularpatternsstoreService := angularpatternsstore.ProvideService(kvStore)
@@ -1375,8 +1376,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
if err != nil {
return nil, err
}
pluginscdnService := pluginscdn.ProvideService(pluginManagementCfg)
pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, signatureSignature, pluginstoreService)
pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, pluginstoreService)
avatarCacheServer := avatar.ProvideAvatarCacheServer(cfg)
prefService := prefimpl.ProvideService(sqlStore, cfg)
dashboardPermissionsService, err := ossaccesscontrol.ProvideDashboardPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, dashboardService, folderimplService, acimplService, teamService, userService, actionSetService, dashboardServiceImpl, eventualRestConfigProvider)

View File

@@ -26,6 +26,7 @@ import (
"github.com/grafana/grafana/pkg/plugins/manager/sources"
"github.com/grafana/grafana/pkg/plugins/pluginassets"
"github.com/grafana/grafana/pkg/plugins/pluginerrs"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline"
"github.com/grafana/grafana/pkg/services/pluginsintegration/provisionedplugins"
@@ -213,10 +214,27 @@ func TestLoader_Load(t *testing.T) {
ExtensionPoints: []plugins.ExtensionPoint{},
},
},
Class: plugins.ClassExternal,
Module: "public/plugins/test-app/module.js",
BaseURL: "public/plugins/test-app",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "includes-symlinks")),
Class: plugins.ClassExternal,
Module: "public/plugins/test-app/module.js",
BaseURL: "public/plugins/test-app",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "includes-symlinks")),
Manifest: &plugins.PluginManifest{
Plugin: "test-app",
Version: "1.0.0",
KeyID: "7e4d0c6a708866e7",
Time: 1622547655175,
Files: map[string]string{
"dashboards/connections.json": "bea86da4be970b98dc4681802ab55cdef3441dc3eb3c654cb207948d17b25303",
"dashboards/extra/memory.json": "7c042464941084caa91d0a9a2f188b05315a9796308a652ccdee31ca4fbcbfee",
"plugin.json": "c59a51bf6d7ecd7a99608ccb99353390c8b973672a938a0247164324005c0caf",
"symlink_to_txt": "9f32c171bf78a85d5cb77a48ab44f85578ee2942a1fc9f9ec4fde194ae4ff048",
"text.txt": "9f32c171bf78a85d5cb77a48ab44f85578ee2942a1fc9f9ec4fde194ae4ff048",
},
ManifestVersion: "2.0.0",
SignatureType: plugins.SignatureTypeGrafana,
SignedByOrg: "grafana",
SignedByOrgName: "Grafana Labs",
},
Signature: "valid",
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
@@ -647,10 +665,24 @@ func TestLoader_Load_MultiplePlugins(t *testing.T) {
Executable: "test",
State: plugins.ReleaseStateAlpha,
},
Class: plugins.ClassExternal,
Module: "public/plugins/test-datasource/module.js",
BaseURL: "public/plugins/test-datasource",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature/plugin")),
Class: plugins.ClassExternal,
Module: "public/plugins/test-datasource/module.js",
BaseURL: "public/plugins/test-datasource",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature/plugin")),
Manifest: &plugins.PluginManifest{
Plugin: "test-datasource",
Version: "1.0.0",
KeyID: "7e4d0c6a708866e7",
Time: 1661171417046,
Files: map[string]string{
"plugin.json": "203ef4a613c5693c437a665cd67f95e2756a0f71b336b2ffb265db7c180d0b19",
},
ManifestVersion: "2.0.0",
SignatureType: plugins.SignatureTypePrivate,
SignedByOrg: "willbrowne",
SignedByOrgName: "Will Browne",
RootURLs: []string{"http://localhost:3000/"},
},
Signature: "valid",
SignatureType: plugins.SignatureTypePrivate,
SignatureOrg: "Will Browne",
@@ -767,8 +799,22 @@ func TestLoader_Load_RBACReady(t *testing.T) {
},
Backend: false,
},
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app-with-roles")),
Class: plugins.ClassExternal,
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app-with-roles")),
Class: plugins.ClassExternal,
Manifest: &plugins.PluginManifest{
Plugin: "test-app",
Version: "1.0.0",
KeyID: "7e4d0c6a708866e7",
Time: 1667484928676,
Files: map[string]string{
"plugin.json": "3348335ec100392b325f3eeb882a07c729e9cbf0f1ae331239f46840bb1a01eb",
},
ManifestVersion: "2.0.0",
SignatureType: plugins.SignatureTypePrivate,
SignedByOrg: "gabrielmabille",
SignedByOrgName: "gabrielmabille",
RootURLs: []string{"http://localhost:3000/"},
},
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypePrivate,
SignatureOrg: "gabrielmabille",
@@ -837,8 +883,22 @@ func TestLoader_Load_Signature_RootURL(t *testing.T) {
Backend: true,
Executable: "test",
},
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature-root-url-uri/plugin")),
Class: plugins.ClassExternal,
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature-root-url-uri/plugin")),
Class: plugins.ClassExternal,
Manifest: &plugins.PluginManifest{
Plugin: "test-datasource",
Version: "1.0.0",
KeyID: "7e4d0c6a708866e7",
Time: 1661171981629,
Files: map[string]string{
"plugin.json": "203ef4a613c5693c437a665cd67f95e2756a0f71b336b2ffb265db7c180d0b19",
},
ManifestVersion: "2.0.0",
SignatureType: plugins.SignatureTypePrivate,
SignedByOrg: "willbrowne",
SignedByOrgName: "Will Browne",
RootURLs: []string{"http://localhost:3000/grafana"},
},
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypePrivate,
SignatureOrg: "Will Browne",
@@ -925,8 +985,24 @@ func TestLoader_Load_DuplicatePlugins(t *testing.T) {
},
Backend: false,
},
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app")),
Class: plugins.ClassExternal,
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app")),
Class: plugins.ClassExternal,
Manifest: &plugins.PluginManifest{
Plugin: "test-app",
Version: "1.0.0",
KeyID: "7e4d0c6a708866e7",
Time: 1621356785895,
Files: map[string]string{
"plugin.json": "c59a51bf6d7ecd7a99608ccb99353390c8b973672a938a0247164324005c0caf",
"dashboards/connections.json": "bea86da4be970b98dc4681802ab55cdef3441dc3eb3c654cb207948d17b25303",
"dashboards/memory.json": "7c042464941084caa91d0a9a2f188b05315a9796308a652ccdee31ca4fbcbfee",
"dashboards/connections_result.json": "124d85c9c2e40214b83273f764574937a79909cfac3f925276fbb72543c224dc",
},
ManifestVersion: "2.0.0",
SignatureType: plugins.SignatureTypeGrafana,
SignedByOrg: "grafana",
SignedByOrgName: "Grafana Labs",
},
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
@@ -1017,8 +1093,24 @@ func TestLoader_Load_SkipUninitializedPlugins(t *testing.T) {
},
Backend: false,
},
FS: mustNewStaticFSForTests(t, pluginDir1),
Class: plugins.ClassExternal,
FS: mustNewStaticFSForTests(t, pluginDir1),
Class: plugins.ClassExternal,
Manifest: &plugins.PluginManifest{
Plugin: "test-app",
Version: "1.0.0",
KeyID: "7e4d0c6a708866e7",
Time: 1621356785895,
Files: map[string]string{
"plugin.json": "c59a51bf6d7ecd7a99608ccb99353390c8b973672a938a0247164324005c0caf",
"dashboards/connections.json": "bea86da4be970b98dc4681802ab55cdef3441dc3eb3c654cb207948d17b25303",
"dashboards/memory.json": "7c042464941084caa91d0a9a2f188b05315a9796308a652ccdee31ca4fbcbfee",
"dashboards/connections_result.json": "124d85c9c2e40214b83273f764574937a79909cfac3f925276fbb72543c224dc",
},
ManifestVersion: "2.0.0",
SignatureType: plugins.SignatureTypeGrafana,
SignedByOrg: "grafana",
SignedByOrgName: "Grafana Labs",
},
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
@@ -1180,9 +1272,23 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
},
Backend: true,
},
Module: "public/plugins/test-datasource/module.js",
BaseURL: "public/plugins/test-datasource",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent")),
Module: "public/plugins/test-datasource/module.js",
BaseURL: "public/plugins/test-datasource",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent")),
Manifest: &plugins.PluginManifest{
Plugin: "test-datasource",
Version: "1.0.0",
KeyID: "7e4d0c6a708866e7",
Time: 1661172777367,
Files: map[string]string{
"plugin.json": "a029469ace740e9502bfb0d40924d1cccae73d0b18adcd8f1ceb7f17bf36beb8",
"nested/plugin.json": "e64abd35cd211e0e4682974ad5cdd1be7a0b7cd24951d302a16d9e2cb6cefea4",
},
ManifestVersion: "2.0.0",
SignatureType: plugins.SignatureTypeGrafana,
SignedByOrg: "grafana",
SignedByOrgName: "Grafana Labs",
},
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
@@ -1225,9 +1331,23 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
ExtensionPoints: []plugins.ExtensionPoint{},
},
},
Module: "public/plugins/test-panel/module.js",
BaseURL: "public/plugins/test-panel",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent/nested")),
Module: "public/plugins/test-panel/module.js",
BaseURL: "public/plugins/test-panel",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent/nested")),
Manifest: &plugins.PluginManifest{
Plugin: "test-datasource",
Version: "1.0.0",
KeyID: "7e4d0c6a708866e7",
Time: 1661172777367,
Files: map[string]string{
"plugin.json": "a029469ace740e9502bfb0d40924d1cccae73d0b18adcd8f1ceb7f17bf36beb8",
"nested/plugin.json": "e64abd35cd211e0e4682974ad5cdd1be7a0b7cd24951d302a16d9e2cb6cefea4",
},
ManifestVersion: "2.0.0",
SignatureType: plugins.SignatureTypeGrafana,
SignedByOrg: "grafana",
SignedByOrgName: "Grafana Labs",
},
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
@@ -1375,10 +1495,25 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
},
Backend: false,
},
Module: "public/plugins/myorgid-simple-app/module.js",
BaseURL: "public/plugins/myorgid-simple-app",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "app-with-child/dist")),
DefaultNavURL: "/plugins/myorgid-simple-app/page/root-page-react",
Module: "public/plugins/myorgid-simple-app/module.js",
BaseURL: "public/plugins/myorgid-simple-app",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "app-with-child/dist")),
DefaultNavURL: "/plugins/myorgid-simple-app/page/root-page-react",
Manifest: &plugins.PluginManifest{
Plugin: "myorgid-simple-app",
Version: "%VERSION%",
KeyID: "7e4d0c6a708866e7",
Time: 1642614241713,
Files: map[string]string{
"plugin.json": "1abecfd0229814f6c284ff3c8dd744548f8d676ab3250cd7902c99dabf11480e",
"child/plugin.json": "66ba0dffaf3b1bfa17eb9a8672918fc66d1001f465b1061f4fc19c2f2c100f51",
},
ManifestVersion: "2.0.0",
SignatureType: plugins.SignatureTypeGrafana,
SignedByOrg: "grafana",
SignedByOrgName: "Grafana Labs",
RootURLs: []string{},
},
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
@@ -1431,6 +1566,21 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
BaseURL: "public/plugins/myorgid-simple-panel",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "app-with-child/dist/child")),
IncludedInAppID: parent.ID,
Manifest: &plugins.PluginManifest{
Plugin: "myorgid-simple-app",
Version: "%VERSION%",
KeyID: "7e4d0c6a708866e7",
Time: 1642614241713,
Files: map[string]string{
"plugin.json": "1abecfd0229814f6c284ff3c8dd744548f8d676ab3250cd7902c99dabf11480e",
"child/plugin.json": "66ba0dffaf3b1bfa17eb9a8672918fc66d1001f465b1061f4fc19c2f2c100f51",
},
ManifestVersion: "2.0.0",
SignatureType: plugins.SignatureTypeGrafana,
SignedByOrg: "grafana",
SignedByOrgName: "Grafana Labs",
RootURLs: []string{},
},
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
@@ -1484,7 +1634,7 @@ func newLoader(t *testing.T, cfg *config.PluginManagementCfg, reg registry.Servi
require.NoError(t, err)
return ProvideService(cfg, pipeline.ProvideDiscoveryStage(cfg, reg),
pipeline.ProvideBootstrapStage(cfg, signature.DefaultCalculator(cfg), pluginAssetsProvider),
pipeline.ProvideBootstrapStage(cfg, signature.DefaultCalculator(cfg), pluginAssetsProvider, pluginscdn.ProvideService(cfg)),
pipeline.ProvideValidationStage(cfg, signature.NewValidator(signature.NewUnsignedAuthorizer(cfg)), angularInspector),
pipeline.ProvideInitializationStage(cfg, reg, backendFactory, proc, &pluginfakes.FakeAuthService{}, pluginfakes.NewFakeRoleRegistry(), pluginfakes.NewFakeActionSetRegistry(), pluginfakes.NewFakePluginEnvProvider(), tracing.InitializeTracerForTest(), provisionedplugins.NewNoop()),
terminate, errTracker)
@@ -1514,7 +1664,7 @@ func newLoaderWithOpts(t *testing.T, cfg *config.PluginManagementCfg, opts loade
}
return ProvideService(cfg, pipeline.ProvideDiscoveryStage(cfg, reg),
pipeline.ProvideBootstrapStage(cfg, signature.DefaultCalculator(cfg), pluginassets.NewLocalProvider()),
pipeline.ProvideBootstrapStage(cfg, signature.DefaultCalculator(cfg), pluginassets.NewLocalProvider(), pluginscdn.ProvideService(cfg)),
pipeline.ProvideValidationStage(cfg, signature.NewValidator(signature.NewUnsignedAuthorizer(cfg)), angularInspector),
pipeline.ProvideInitializationStage(cfg, reg, backendFactoryProvider, proc, authServiceRegistry, pluginfakes.NewFakeRoleRegistry(), pluginfakes.NewFakeActionSetRegistry(), pluginfakes.NewFakePluginEnvProvider(), tracing.InitializeTracerForTest(), provisionedplugins.NewNoop()),
terminate, errTracker)

View File

@@ -18,6 +18,7 @@ import (
"github.com/grafana/grafana/pkg/plugins/manager/registry"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/pluginassets"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
"github.com/grafana/grafana/pkg/services/pluginsintegration/coreplugin"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol"
"github.com/grafana/grafana/pkg/services/pluginsintegration/provisionedplugins"
@@ -42,7 +43,7 @@ func ProvideDiscoveryStage(cfg *config.PluginManagementCfg, pr registry.Service)
})
}
func ProvideBootstrapStage(cfg *config.PluginManagementCfg, sc plugins.SignatureCalculator, ap pluginassets.Provider) *bootstrap.Bootstrap {
func ProvideBootstrapStage(cfg *config.PluginManagementCfg, sc plugins.SignatureCalculator, ap pluginassets.Provider, cdn *pluginscdn.Service) *bootstrap.Bootstrap {
disableAlertingForTempoDecorateFunc := func(ctx context.Context, p *plugins.Plugin) (*plugins.Plugin, error) {
if p.ID == coreplugin.Tempo && !cfg.Features.TempoAlertingEnabled {
p.Alerting = false
@@ -52,7 +53,7 @@ func ProvideBootstrapStage(cfg *config.PluginManagementCfg, sc plugins.Signature
return bootstrap.New(cfg, bootstrap.Opts{
ConstructFunc: bootstrap.DefaultConstructFunc(cfg, sc, ap),
DecorateFuncs: append(bootstrap.DefaultDecorateFuncs(cfg), disableAlertingForTempoDecorateFunc),
DecorateFuncs: append(bootstrap.DefaultDecorateFuncs(cfg, cdn), disableAlertingForTempoDecorateFunc),
})
}

View File

@@ -2,19 +2,12 @@ package pluginassets
import (
"context"
"encoding/base64"
"encoding/hex"
"fmt"
"path"
"path/filepath"
"sync"
"github.com/Masterminds/semver/v3"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
)
@@ -28,24 +21,20 @@ var (
scriptLoadingMinSupportedVersion = semver.MustParse(CreatePluginVersionScriptSupportEnabled)
)
func ProvideService(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service, sig *signature.Signature, store pluginstore.Store) *Service {
func ProvideService(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service, store pluginstore.Store) *Service {
return &Service{
cfg: cfg,
cdn: cdn,
signature: sig,
store: store,
log: log.New("pluginassets"),
cfg: cfg,
cdn: cdn,
store: store,
log: log.New("pluginassets"),
}
}
type Service struct {
cfg *config.PluginManagementCfg
cdn *pluginscdn.Service
signature *signature.Signature
store pluginstore.Store
log log.Logger
moduleHashCache sync.Map
cfg *config.PluginManagementCfg
cdn *pluginscdn.Service
store pluginstore.Store
log log.Logger
}
// LoadingStrategy calculates the loading strategy for a plugin.
@@ -82,95 +71,6 @@ func (s *Service) LoadingStrategy(_ context.Context, p pluginstore.Plugin) plugi
return plugins.LoadingStrategyFetch
}
// ModuleHash returns the module.js SHA256 hash for a plugin in the format expected by the browser for SRI checks.
// The module hash is read from the plugin's MANIFEST.txt file.
// The plugin can also be a nested plugin.
// If the plugin is unsigned, an empty string is returned.
// The results are cached to avoid repeated reads from the MANIFEST.txt file.
func (s *Service) ModuleHash(ctx context.Context, p pluginstore.Plugin) string {
k := s.moduleHashCacheKey(p)
cachedValue, ok := s.moduleHashCache.Load(k)
if ok {
return cachedValue.(string)
}
mh, err := s.moduleHash(ctx, p, "")
if err != nil {
s.log.Error("Failed to calculate module hash", "plugin", p.ID, "error", err)
}
s.moduleHashCache.Store(k, mh)
return mh
}
// moduleHash is the underlying function for ModuleHash. See its documentation for more information.
// If the plugin is not a CDN plugin, the function will return an empty string.
// It will read the module hash from the MANIFEST.txt in the [[plugins.FS]] of the provided plugin.
// If childFSBase is provided, the function will try to get the hash from MANIFEST.txt for the provided children's
// module.js file, rather than for the provided plugin.
func (s *Service) moduleHash(ctx context.Context, p pluginstore.Plugin, childFSBase string) (r string, err error) {
if !s.cfg.Features.SriChecksEnabled {
return "", nil
}
// Ignore unsigned plugins
if !p.Signature.IsValid() {
return "", nil
}
if p.Parent != nil {
// Nested plugin
parent, ok := s.store.Plugin(ctx, p.Parent.ID)
if !ok {
return "", fmt.Errorf("parent plugin plugin %q for child plugin %q not found", p.Parent.ID, p.ID)
}
// The module hash is contained within the parent's MANIFEST.txt file.
// For example, the parent's MANIFEST.txt will contain an entry similar to this:
//
// ```
// "datasource/module.js": "1234567890abcdef..."
// ```
//
// Recursively call moduleHash with the parent plugin and with the children plugin folder path
// to get the correct module hash for the nested plugin.
if childFSBase == "" {
childFSBase = p.Base()
}
return s.moduleHash(ctx, parent, childFSBase)
}
// Only CDN plugins are supported for SRI checks.
// CDN plugins have the version as part of the URL, which acts as a cache-buster.
// Needed due to: https://github.com/grafana/plugin-tools/pull/1426
// FS plugins build before this change will have SRI mismatch issues.
if !s.cdnEnabled(p.ID, p.FS) {
return "", nil
}
manifest, err := s.signature.ReadPluginManifestFromFS(ctx, p.FS)
if err != nil {
return "", fmt.Errorf("read plugin manifest: %w", err)
}
if !manifest.IsV2() {
return "", nil
}
var childPath string
if childFSBase != "" {
// Calculate the relative path of the child plugin folder from the parent plugin folder.
childPath, err = p.FS.Rel(childFSBase)
if err != nil {
return "", fmt.Errorf("rel path: %w", err)
}
// MANIFETS.txt uses forward slashes as path separators.
childPath = filepath.ToSlash(childPath)
}
moduleHash, ok := manifest.Files[path.Join(childPath, "module.js")]
if !ok {
return "", nil
}
return convertHashForSRI(moduleHash)
}
func (s *Service) compatibleCreatePluginVersion(ps map[string]string) bool {
if cpv, ok := ps[CreatePluginVersionCfgKey]; ok {
createPluginVer, err := semver.NewVersion(cpv)
@@ -188,17 +88,3 @@ func (s *Service) compatibleCreatePluginVersion(ps map[string]string) bool {
func (s *Service) cdnEnabled(pluginID string, fs plugins.FS) bool {
return s.cdn.PluginSupported(pluginID) || fs.Type().CDN()
}
// convertHashForSRI takes a SHA256 hash string and returns it as expected by the browser for SRI checks.
func convertHashForSRI(h string) (string, error) {
hb, err := hex.DecodeString(h)
if err != nil {
return "", fmt.Errorf("hex decode string: %w", err)
}
return "sha256-" + base64.StdEncoding.EncodeToString(hb), nil
}
// moduleHashCacheKey returns a unique key for the module hash cache.
func (s *Service) moduleHashCacheKey(p pluginstore.Plugin) string {
return p.ID + ":" + p.Info.Version
}

View File

@@ -2,19 +2,14 @@ package pluginassets
import (
"context"
"fmt"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/manager/pluginfakes"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
)
@@ -179,349 +174,6 @@ func TestService_Calculate(t *testing.T) {
}
}
func TestService_ModuleHash(t *testing.T) {
const (
pluginID = "grafana-test-datasource"
parentPluginID = "grafana-test-app"
)
for _, tc := range []struct {
name string
features *config.Features
store []pluginstore.Plugin
// Can be used to configure plugin's fs
// fs cdn type = loaded from CDN with no files on disk
// fs local type = files on disk but served from CDN only if cdn=true
plugin pluginstore.Plugin
// When true, set cdn=true in config
cdn bool
expModuleHash string
}{
{
name: "unsigned should not return module hash",
plugin: newPlugin(pluginID, withSignatureStatus(plugins.SignatureStatusUnsigned)),
cdn: false,
features: &config.Features{SriChecksEnabled: false},
expModuleHash: "",
},
{
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
withClass(plugins.ClassExternal),
),
cdn: true,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"),
},
{
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
withClass(plugins.ClassExternal),
),
cdn: true,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"),
},
{
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
),
cdn: false,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: "",
},
{
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
),
cdn: true,
features: &config.Features{SriChecksEnabled: false},
expModuleHash: "",
},
{
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
),
cdn: false,
features: &config.Features{SriChecksEnabled: false},
expModuleHash: "",
},
{
// parentPluginID (/)
// └── pluginID (/datasource)
name: "nested plugin should return module hash from parent MANIFEST.txt",
store: []pluginstore.Plugin{
newPlugin(
parentPluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))),
),
},
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "datasource"))),
withParent(parentPluginID),
),
cdn: true,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: newSRIHash(t, "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711"),
},
{
// parentPluginID (/)
// └── pluginID (/panels/one)
name: "nested plugin deeper than one subfolder should return module hash from parent MANIFEST.txt",
store: []pluginstore.Plugin{
newPlugin(
parentPluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))),
),
},
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one"))),
withParent(parentPluginID),
),
cdn: true,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: newSRIHash(t, "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f"),
},
{
// grand-parent-app (/)
// ├── parent-datasource (/datasource)
// │ └── child-panel (/datasource/panels/one)
name: "nested plugin of a nested plugin should return module hash from parent MANIFEST.txt",
store: []pluginstore.Plugin{
newPlugin(
"grand-parent-app",
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested"))),
),
newPlugin(
"parent-datasource",
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource"))),
withParent("grand-parent-app"),
),
},
plugin: newPlugin(
"child-panel",
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource", "panels", "one"))),
withParent("parent-datasource"),
),
cdn: true,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: newSRIHash(t, "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f"),
},
{
name: "nested plugin should not return module hash from parent if it's not registered in the store",
store: []pluginstore.Plugin{},
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one"))),
withParent(parentPluginID),
),
cdn: false,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: "",
},
{
name: "missing module.js entry from MANIFEST.txt should not return module hash",
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-module-js"))),
),
cdn: false,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: "",
},
{
name: "signed status but missing MANIFEST.txt should not return module hash",
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-manifest-txt"))),
),
cdn: false,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: "",
},
} {
if tc.name == "" {
var expS string
if tc.expModuleHash == "" {
expS = "should not return module hash"
} else {
expS = "should return module hash"
}
tc.name = fmt.Sprintf("feature=%v, cdn_config=%v, class=%v %s", tc.features.SriChecksEnabled, tc.cdn, tc.plugin.Class, expS)
}
t.Run(tc.name, func(t *testing.T) {
var pluginSettings config.PluginSettings
if tc.cdn {
pluginSettings = config.PluginSettings{
pluginID: {
"cdn": "true",
},
parentPluginID: map[string]string{
"cdn": "true",
},
"grand-parent-app": map[string]string{
"cdn": "true",
},
}
}
features := tc.features
if features == nil {
features = &config.Features{}
}
pCfg := &config.PluginManagementCfg{
PluginsCDNURLTemplate: "http://cdn.example.com",
PluginSettings: pluginSettings,
Features: *features,
}
svc := ProvideService(
pCfg,
pluginscdn.ProvideService(pCfg),
signature.ProvideService(pCfg, statickey.New()),
pluginstore.NewFakePluginStore(tc.store...),
)
mh := svc.ModuleHash(context.Background(), tc.plugin)
require.Equal(t, tc.expModuleHash, mh)
})
}
}
func TestService_ModuleHash_Cache(t *testing.T) {
pCfg := &config.PluginManagementCfg{
PluginSettings: config.PluginSettings{},
Features: config.Features{SriChecksEnabled: true},
}
svc := ProvideService(
pCfg,
pluginscdn.ProvideService(pCfg),
signature.ProvideService(pCfg, statickey.New()),
pluginstore.NewFakePluginStore(),
)
const pluginID = "grafana-test-datasource"
t.Run("cache key", func(t *testing.T) {
t.Run("with version", func(t *testing.T) {
const pluginVersion = "1.0.0"
p := newPlugin(pluginID, withInfo(plugins.Info{Version: pluginVersion}))
k := svc.moduleHashCacheKey(p)
require.Equal(t, pluginID+":"+pluginVersion, k, "cache key should be correct")
})
t.Run("without version", func(t *testing.T) {
p := newPlugin(pluginID)
k := svc.moduleHashCacheKey(p)
require.Equal(t, pluginID+":", k, "cache key should be correct")
})
})
t.Run("ModuleHash usage", func(t *testing.T) {
pV1 := newPlugin(
pluginID,
withInfo(plugins.Info{Version: "1.0.0"}),
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
)
pCfg = &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.grafana.com",
PluginSettings: config.PluginSettings{
pluginID: {
"cdn": "true",
},
},
Features: config.Features{SriChecksEnabled: true},
}
svc = ProvideService(
pCfg,
pluginscdn.ProvideService(pCfg),
signature.ProvideService(pCfg, statickey.New()),
pluginstore.NewFakePluginStore(),
)
k := svc.moduleHashCacheKey(pV1)
_, ok := svc.moduleHashCache.Load(k)
require.False(t, ok, "cache should initially be empty")
mhV1 := svc.ModuleHash(context.Background(), pV1)
pV1Exp := newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03")
require.Equal(t, pV1Exp, mhV1, "returned value should be correct")
cachedMh, ok := svc.moduleHashCache.Load(k)
require.True(t, ok)
require.Equal(t, pV1Exp, cachedMh, "cache should contain the returned value")
t.Run("different version uses different cache key", func(t *testing.T) {
pV2 := newPlugin(
pluginID,
withInfo(plugins.Info{Version: "2.0.0"}),
withSignatureStatus(plugins.SignatureStatusValid),
// different fs for different hash
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))),
)
mhV2 := svc.ModuleHash(context.Background(), pV2)
require.NotEqual(t, mhV2, mhV1, "different version should have different hash")
require.Equal(t, newSRIHash(t, "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a"), mhV2)
})
t.Run("cache should be used", func(t *testing.T) {
// edit cache directly
svc.moduleHashCache.Store(k, "hax")
require.Equal(t, "hax", svc.ModuleHash(context.Background(), pV1))
})
})
}
func TestConvertHashFromSRI(t *testing.T) {
for _, tc := range []struct {
hash string
expHash string
expErr bool
}{
{
hash: "ddfcb449445064e6c39f0c20b15be3cb6a55837cf4781df23d02de005f436811",
expHash: "sha256-3fy0SURQZObDnwwgsVvjy2pVg3z0eB3yPQLeAF9DaBE=",
},
{
hash: "not-a-valid-hash",
expErr: true,
},
} {
t.Run(tc.hash, func(t *testing.T) {
r, err := convertHashForSRI(tc.hash)
if tc.expErr {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tc.expHash, r)
}
})
}
}
func newPlugin(pluginID string, cbs ...func(p pluginstore.Plugin) pluginstore.Plugin) pluginstore.Plugin {
p := pluginstore.Plugin{
JSONData: plugins.JSONData{
@@ -534,13 +186,6 @@ func newPlugin(pluginID string, cbs ...func(p pluginstore.Plugin) pluginstore.Pl
return p
}
func withInfo(info plugins.Info) func(p pluginstore.Plugin) pluginstore.Plugin {
return func(p pluginstore.Plugin) pluginstore.Plugin {
p.Info = info
return p
}
}
func withFS(fs plugins.FS) func(p pluginstore.Plugin) pluginstore.Plugin {
return func(p pluginstore.Plugin) pluginstore.Plugin {
p.FS = fs
@@ -548,13 +193,6 @@ func withFS(fs plugins.FS) func(p pluginstore.Plugin) pluginstore.Plugin {
}
}
func withSignatureStatus(status plugins.SignatureStatus) func(p pluginstore.Plugin) pluginstore.Plugin {
return func(p pluginstore.Plugin) pluginstore.Plugin {
p.Signature = status
return p
}
}
func withAngular(angular bool) func(p pluginstore.Plugin) pluginstore.Plugin {
return func(p pluginstore.Plugin) pluginstore.Plugin {
p.Angular = plugins.AngularMeta{Detected: angular}
@@ -562,13 +200,6 @@ func withAngular(angular bool) func(p pluginstore.Plugin) pluginstore.Plugin {
}
}
func withParent(parentID string) func(p pluginstore.Plugin) pluginstore.Plugin {
return func(p pluginstore.Plugin) pluginstore.Plugin {
p.Parent = &pluginstore.ParentPlugin{ID: parentID}
return p
}
}
func withClass(class plugins.Class) func(p pluginstore.Plugin) pluginstore.Plugin {
return func(p pluginstore.Plugin) pluginstore.Plugin {
p.Class = class
@@ -587,9 +218,3 @@ func newPluginSettings(pluginID string, kv map[string]string) config.PluginSetti
pluginID: kv,
}
}
func newSRIHash(t *testing.T, s string) string {
r, err := convertHashForSRI(s)
require.NoError(t, err)
return r
}

View File

@@ -30,8 +30,9 @@ type Plugin struct {
Error *plugins.Error
// SystemJS fields
Module string
BaseURL string
Module string
BaseURL string
ModuleHash string
Angular plugins.AngularMeta
@@ -80,6 +81,7 @@ func ToGrafanaDTO(p *plugins.Plugin) Plugin {
ExternalService: p.ExternalService,
Angular: p.Angular,
Translations: p.Translations,
ModuleHash: p.ModuleHash,
}
if p.Parent != nil {

View File

@@ -24,6 +24,7 @@ import (
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
"github.com/grafana/grafana/pkg/plugins/pluginassets"
"github.com/grafana/grafana/pkg/plugins/pluginerrs"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/pluginsintegration/coreplugin"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline"
@@ -49,7 +50,7 @@ func CreateIntegrationTestCtx(t *testing.T, cfg *setting.Cfg, coreRegistry *core
proc := process.ProvideService()
disc := pipeline.ProvideDiscoveryStage(pCfg, reg)
boot := pipeline.ProvideBootstrapStage(pCfg, signature.ProvideService(pCfg, statickey.New()), pluginassets.NewLocalProvider())
boot := pipeline.ProvideBootstrapStage(pCfg, signature.ProvideService(pCfg, statickey.New()), pluginassets.NewLocalProvider(), pluginscdn.ProvideService(pCfg))
valid := pipeline.ProvideValidationStage(pCfg, signature.NewValidator(signature.NewUnsignedAuthorizer(pCfg)), angularInspector)
init := pipeline.ProvideInitializationStage(pCfg, reg, coreplugin.ProvideCoreProvider(coreRegistry), proc, &pluginfakes.FakeAuthService{}, pluginfakes.NewFakeRoleRegistry(), pluginfakes.NewFakeActionSetRegistry(), nil, tracing.InitializeTracerForTest(), provisionedplugins.NewNoop())
term, err := pipeline.ProvideTerminationStage(pCfg, reg, proc)
@@ -87,7 +88,7 @@ func CreateTestLoader(t *testing.T, cfg *pluginsCfg.PluginManagementCfg, opts Lo
}
if opts.Bootstrapper == nil {
opts.Bootstrapper = pipeline.ProvideBootstrapStage(cfg, signature.ProvideService(cfg, statickey.New()), pluginassets.NewLocalProvider())
opts.Bootstrapper = pipeline.ProvideBootstrapStage(cfg, signature.ProvideService(cfg, statickey.New()), pluginassets.NewLocalProvider(), pluginscdn.ProvideService(cfg))
}
if opts.Validator == nil {