Plugins: Add Subresource Integrity checks (#93024)

* Plugins: Pass hashes for SRI to frontend

* Add SRI hashes to frontendsettings DTOs

* Add docstring

* TestSriHashes

* Fix typo

* Changed SriHashes to ModuleHash

* update loader_test compareOpts

* update ModuleHash error message

* Add TestModuleHash/no_module.js

* Add omitEmpty to moduleHash

* Add ModuleHash to api/plugins/${pluginId}/settings

* moved ModuleHash field

* feat(plugins): add moduleHash to bootData and plugin types

* feat(plugins): if moduleHash is available apply it to systemjs importmap

* Calculate ModuleHash for CDN provisioned plugins

* Add ModuleHash tests for TestCalculate

* adjust test case name

* removed .envrc

* Fix signature verification failing for internal plugins

* fix tests

* Add pluginsFilesystemSriChecks feature togglemk

* renamed FilesystemSriChecksEnabled

* refactor(plugin_loader): prefer extending type declaration over ts-error

* added a couple more tests

* Removed unused features

* Removed unused argument from signature.DefaultCalculator call

* Removed unused argument from bootstrap.DefaultConstructFunc

* Moved ModuleHash to pluginassets service

* update docstring

* lint

* Removed cdn dependency from manifest.Signature

* add tests

* fix extra parameters in tests

* "fix" tests

* removed outdated test

* removed unused cdn dependency in signature.DefaultCalculator

* reduce diff

* Cache returned values

* Add support for deeply nested plugins (more than 1 hierarchy level)

* simplify cache usage

* refactor TestService_ModuleHash_Cache

* removed unused testdata

* re-generate feature toggles

* use version for module hash cache

* Renamed feature toggle to pluginsSriChecks and use it for both cdn and filesystem

* Removed app/types/system-integrity.d.ts

* re-generate feature toggles

* re-generate feature toggles

* feat(plugins): put systemjs integrity hash behind feature flag

---------

Co-authored-by: Jack Westbrook <jack.westbrook@gmail.com>
This commit is contained in:
Giuseppe Guerra 2024-10-04 14:55:09 +02:00 committed by GitHub
parent 153036be2e
commit 0db65d229e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 901 additions and 104 deletions

View File

@ -207,6 +207,7 @@ Experimental features might be changed or removed without prior notice.
| `appSidecar` | Enable the app sidecar feature that allows rendering 2 apps at the same time |
| `alertingQueryAndExpressionsStepMode` | Enables step mode for alerting queries and expressions |
| `rolePickerDrawer` | Enables the new role picker drawer design |
| `pluginsSriChecks` | Enables SRI checks for plugin assets |
## Development feature toggles

View File

@ -219,4 +219,5 @@ export interface FeatureToggles {
useSessionStorageForRedirection?: boolean;
rolePickerDrawer?: boolean;
unifiedStorageSearch?: boolean;
pluginsSriChecks?: boolean;
}

View File

@ -99,6 +99,7 @@ export interface PluginMeta<T extends KeyValue = {}> {
angularDetected?: boolean;
loadingStrategy?: PluginLoadingStrategy;
extensions?: PluginExtensions;
moduleHash?: string;
}
interface PluginDependencyInfo {

View File

@ -46,6 +46,7 @@ export type AppPluginConfig = {
loadingStrategy: PluginLoadingStrategy;
dependencies: PluginDependencies;
extensions: PluginExtensions;
moduleHash?: string;
};
export type PreinstalledPlugin = {

View File

@ -30,6 +30,7 @@ type PluginSetting struct {
SignatureOrg string `json:"signatureOrg"`
AngularDetected bool `json:"angularDetected"`
LoadingStrategy plugins.LoadingStrategy `json:"loadingStrategy"`
ModuleHash string `json:"moduleHash,omitempty"`
}
type PluginListItem struct {

View File

@ -145,6 +145,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),
BaseURL: panel.BaseURL,
SkipDataQuery: panel.SkipDataQuery,
HideFromList: panel.HideFromList,
@ -453,6 +454,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),
BaseURL: plugin.BaseURL,
Angular: plugin.Angular,
MultiValueFilterOperators: plugin.MultiValueFilterOperators,
@ -538,8 +540,9 @@ func (hs *HTTPServer) getFSDataSources(c *contextmodel.ReqContext, availablePlug
JSONData: ds.JSONData,
Signature: ds.Signature,
Module: ds.Module,
BaseURL: ds.BaseURL,
Angular: ds.Angular,
// ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), ds),
BaseURL: ds.BaseURL,
Angular: ds.Angular,
},
}
if ds.Name == grafanads.DatasourceName {
@ -563,6 +566,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),
}
if settings.Enabled {

View File

@ -18,6 +18,8 @@ import (
"github.com/grafana/grafana/pkg/login/social/socialimpl"
"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/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"
@ -51,10 +53,11 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.F
})
}
pluginsCDN := pluginscdn.ProvideService(&config.PluginManagementCfg{
pluginsCfg := &config.PluginManagementCfg{
PluginsCDNURLTemplate: cfg.PluginsCDNURLTemplate,
PluginSettings: cfg.PluginSettings,
})
}
pluginsCDN := pluginscdn.ProvideService(pluginsCfg)
var pluginStore = pstore
if pluginStore == nil {
@ -68,7 +71,8 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.F
var pluginsAssets = passets
if pluginsAssets == nil {
pluginsAssets = pluginassets.ProvideService(cfg, pluginsCDN)
sig := signature.ProvideService(pluginsCfg, statickey.New())
pluginsAssets = pluginassets.ProvideService(pluginsCfg, pluginsCDN, sig, pluginStore)
}
hs := &HTTPServer{
@ -240,6 +244,7 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
PluginList: []pluginstore.Plugin{
{
Module: fmt.Sprintf("/%s/module.js", "test-app"),
// ModuleHash: "sha256-test",
JSONData: plugins.JSONData{
ID: "test-app",
Info: plugins.Info{Version: "0.5.0"},
@ -255,9 +260,7 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Plugins: newAppSettings("test-app", false),
}
},
pluginAssets: func() *pluginassets.Service {
return pluginassets.ProvideService(setting.NewCfg(), pluginscdn.ProvideService(&config.PluginManagementCfg{}))
},
pluginAssets: newPluginAssets(),
expected: settings{
Apps: map[string]*plugins.AppDTO{
"test-app": {
@ -266,6 +269,7 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Path: "/test-app/module.js",
Version: "0.5.0",
LoadingStrategy: plugins.LoadingStrategyScript,
// ModuleHash: "sha256-test",
},
},
},
@ -277,6 +281,7 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
PluginList: []pluginstore.Plugin{
{
Module: fmt.Sprintf("/%s/module.js", "test-app"),
// ModuleHash: "sha256-test",
JSONData: plugins.JSONData{
ID: "test-app",
Info: plugins.Info{Version: "0.5.0"},
@ -292,9 +297,7 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Plugins: newAppSettings("test-app", true),
}
},
pluginAssets: func() *pluginassets.Service {
return pluginassets.ProvideService(setting.NewCfg(), pluginscdn.ProvideService(&config.PluginManagementCfg{}))
},
pluginAssets: newPluginAssets(),
expected: settings{
Apps: map[string]*plugins.AppDTO{
"test-app": {
@ -303,6 +306,7 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Path: "/test-app/module.js",
Version: "0.5.0",
LoadingStrategy: plugins.LoadingStrategyScript,
// ModuleHash: "sha256-test",
},
},
},
@ -330,9 +334,7 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Plugins: newAppSettings("test-app", true),
}
},
pluginAssets: func() *pluginassets.Service {
return pluginassets.ProvideService(setting.NewCfg(), pluginscdn.ProvideService(&config.PluginManagementCfg{}))
},
pluginAssets: newPluginAssets(),
expected: settings{
Apps: map[string]*plugins.AppDTO{
"test-app": {
@ -368,15 +370,13 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Plugins: newAppSettings("test-app", true),
}
},
pluginAssets: func() *pluginassets.Service {
return pluginassets.ProvideService(&setting.Cfg{
PluginSettings: map[string]map[string]string{
"test-app": {
pluginassets.CreatePluginVersionCfgKey: pluginassets.CreatePluginVersionScriptSupportEnabled,
},
pluginAssets: newPluginAssetsWithConfig(&config.PluginManagementCfg{
PluginSettings: map[string]map[string]string{
"test-app": {
pluginassets.CreatePluginVersionCfgKey: pluginassets.CreatePluginVersionScriptSupportEnabled,
},
}, pluginscdn.ProvideService(&config.PluginManagementCfg{}))
},
},
}),
expected: settings{
Apps: map[string]*plugins.AppDTO{
"test-app": {
@ -412,9 +412,7 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Plugins: newAppSettings("test-app", true),
}
},
pluginAssets: func() *pluginassets.Service {
return pluginassets.ProvideService(setting.NewCfg(), pluginscdn.ProvideService(&config.PluginManagementCfg{}))
},
pluginAssets: newPluginAssets(),
expected: settings{
Apps: map[string]*plugins.AppDTO{
"test-app": {
@ -456,3 +454,13 @@ func newAppSettings(id string, enabled bool) map[string]*pluginsettings.DTO {
},
}
}
func newPluginAssets() func() *pluginassets.Service {
return newPluginAssetsWithConfig(&config.PluginManagementCfg{})
}
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{})
}
}

View File

@ -201,6 +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),
DefaultNavUrl: path.Join(hs.Cfg.AppSubURL, plugin.DefaultNavURL),
State: plugin.State,
Signature: plugin.Signature,

View File

@ -27,6 +27,8 @@ import (
"github.com/grafana/grafana/pkg/plugins/manager/fakes"
"github.com/grafana/grafana/pkg/plugins/manager/filestore"
"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/pfs"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
@ -788,7 +790,6 @@ func Test_PluginsSettings(t *testing.T) {
Info: plugins.Info{
Version: "1.0.0",
}}, plugins.ClassExternal, plugins.NewFakeFS())
pluginRegistry := &fakes.FakePluginRegistry{
Store: map[string]*plugins.Plugin{
p1.ID: p1,
@ -843,8 +844,10 @@ func Test_PluginsSettings(t *testing.T) {
ErrorCode: tc.errCode,
})
}
pluginCDN := pluginscdn.ProvideService(&config.PluginManagementCfg{})
hs.pluginAssets = pluginassets.ProvideService(hs.Cfg, pluginCDN)
pCfg := &config.PluginManagementCfg{}
pluginCDN := pluginscdn.ProvideService(pCfg)
sig := signature.ProvideService(pCfg, statickey.New())
hs.pluginAssets = pluginassets.ProvideService(pCfg, pluginCDN, sig, hs.pluginStore)
hs.pluginErrorResolver = pluginerrs.ProvideStore(errTracker)
var err error
hs.pluginsUpdateChecker, err = updatechecker.ProvidePluginsService(hs.Cfg, nil, tracing.InitializeTracerForTest())

View File

@ -32,6 +32,7 @@ type PluginManagementCfg struct {
type Features struct {
ExternalCorePluginsEnabled bool
SkipHostEnvVarsEnabled bool
SriChecksEnabled bool
}
// NewPluginManagementCfg returns a new PluginManagementCfg.

View File

@ -53,7 +53,7 @@ type PluginManifest struct {
RootURLs []string `json:"rootUrls"`
}
func (m *PluginManifest) isV2() bool {
func (m *PluginManifest) IsV2() bool {
return strings.HasPrefix(m.ManifestVersion, "2.")
}
@ -107,34 +107,17 @@ func (s *Signature) readPluginManifest(ctx context.Context, body []byte) (*Plugi
return &manifest, nil
}
func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plugin plugins.FoundPlugin) (plugins.Signature, error) {
if defaultSignature, exists := src.DefaultSignature(ctx); exists {
return defaultSignature, nil
}
fsFiles, err := plugin.FS.Files()
if err != nil {
return plugins.Signature{}, 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
}
var ErrSignatureTypeUnsigned = errors.New("plugin is unsigned")
f, err := plugin.FS.Open("MANIFEST.txt")
// 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) {
f, err := pfs.Open("MANIFEST.txt")
if err != nil {
if errors.Is(err, plugins.ErrFileNotExist) {
s.log.Debug("Could not find a MANIFEST.txt", "id", plugin.JSONData.ID, "error", err)
return plugins.Signature{
Status: plugins.SignatureStatusUnsigned,
}, nil
return nil, fmt.Errorf("%w: could not find a MANIFEST.txt", ErrSignatureTypeUnsigned)
}
s.log.Debug("Could not open MANIFEST.txt", "id", plugin.JSONData.ID, "error", err)
return plugins.Signature{
Status: plugins.SignatureStatusInvalid,
}, nil
return nil, fmt.Errorf("could not open MANIFEST.txt: %w", err)
}
defer func() {
if f == nil {
@ -147,21 +130,47 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu
byteValue, err := io.ReadAll(f)
if err != nil || len(byteValue) < 10 {
s.log.Debug("MANIFEST.TXT is invalid", "id", plugin.JSONData.ID)
return plugins.Signature{
Status: plugins.SignatureStatusUnsigned,
}, nil
return nil, fmt.Errorf("%w: MANIFEST.txt is invalid", ErrSignatureTypeUnsigned)
}
manifest, err := s.readPluginManifest(ctx, byteValue)
if err != nil {
s.log.Warn("Plugin signature invalid", "id", plugin.JSONData.ID, "error", err)
return nil, err
}
return manifest, nil
}
func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plugin plugins.FoundPlugin) (plugins.Signature, error) {
if defaultSignature, exists := src.DefaultSignature(ctx); exists {
return defaultSignature, nil
}
manifest, err := s.ReadPluginManifestFromFS(ctx, plugin.FS)
switch {
case errors.Is(err, ErrSignatureTypeUnsigned):
s.log.Warn("Plugin is unsigned", "id", plugin.JSONData.ID, "err", err)
return plugins.Signature{
Status: plugins.SignatureStatusUnsigned,
}, nil
case err != nil:
s.log.Warn("Plugin signature is invalid", "id", plugin.JSONData.ID, "err", err)
return plugins.Signature{
Status: plugins.SignatureStatusInvalid,
}, nil
}
if !manifest.isV2() {
if !manifest.IsV2() {
return plugins.Signature{
Status: plugins.SignatureStatusInvalid,
}, nil
}
fsFiles, err := plugin.FS.Files()
if err != nil {
return plugins.Signature{}, 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
@ -328,7 +337,7 @@ func (s *Signature) validateManifest(ctx context.Context, m PluginManifest, bloc
if len(m.Files) == 0 {
return invalidFieldErr{field: "files"}
}
if m.isV2() {
if m.IsV2() {
if len(m.SignedByOrg) == 0 {
return invalidFieldErr{field: "signedByOrg"}
}

View File

@ -19,6 +19,14 @@ import (
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
)
func provideDefaultTestService() *Signature {
return provideTestServiceWithConfig(&config.PluginManagementCfg{})
}
func provideTestServiceWithConfig(cfg *config.PluginManagementCfg) *Signature {
return ProvideService(cfg, statickey.New())
}
func TestReadPluginManifest(t *testing.T) {
txt := `-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512
@ -52,7 +60,7 @@ NR7DnB0CCQHO+4FlSPtXFTzNepoc+CytQyDAeOLMLmf2Tqhk2YShk+G/YlVX
-----END PGP SIGNATURE-----`
t.Run("valid manifest", func(t *testing.T) {
s := ProvideService(&config.PluginManagementCfg{}, statickey.New())
s := provideDefaultTestService()
manifest, err := s.readPluginManifest(context.Background(), []byte(txt))
require.NoError(t, err)
@ -68,8 +76,8 @@ NR7DnB0CCQHO+4FlSPtXFTzNepoc+CytQyDAeOLMLmf2Tqhk2YShk+G/YlVX
})
t.Run("invalid manifest", func(t *testing.T) {
s := provideDefaultTestService()
modified := strings.ReplaceAll(txt, "README.md", "xxxxxxxxxx")
s := ProvideService(&config.PluginManagementCfg{}, statickey.New())
_, err := s.readPluginManifest(context.Background(), []byte(modified))
require.Error(t, err)
})
@ -107,7 +115,7 @@ khdr/tZ1PDgRxMqB/u+Vtbpl0xSxgblnrDOYMSI=
-----END PGP SIGNATURE-----`
t.Run("valid manifest", func(t *testing.T) {
s := ProvideService(&config.PluginManagementCfg{}, statickey.New())
s := provideDefaultTestService()
manifest, err := s.readPluginManifest(context.Background(), []byte(txt))
require.NoError(t, err)
@ -126,6 +134,12 @@ khdr/tZ1PDgRxMqB/u+Vtbpl0xSxgblnrDOYMSI=
}
func TestCalculate(t *testing.T) {
parentDir, err := filepath.Abs("../")
if err != nil {
t.Errorf("could not construct absolute path of current dir")
return
}
t.Run("Validate root URL against App URL for non-private plugin if is specified in manifest", func(t *testing.T) {
tcs := []struct {
appURL string
@ -147,15 +161,9 @@ func TestCalculate(t *testing.T) {
},
}
parentDir, err := filepath.Abs("../")
if err != nil {
t.Errorf("could not construct absolute path of current dir")
return
}
for _, tc := range tcs {
basePath := filepath.Join(parentDir, "testdata/non-pvt-with-root-url/plugin")
s := ProvideService(&config.PluginManagementCfg{GrafanaAppURL: tc.appURL}, statickey.New())
s := provideTestServiceWithConfig(&config.PluginManagementCfg{GrafanaAppURL: tc.appURL})
sig, err := s.Calculate(context.Background(), &fakes.FakePluginSource{
PluginClassFunc: func(ctx context.Context) plugins.Class {
return plugins.ClassExternal
@ -183,7 +191,7 @@ func TestCalculate(t *testing.T) {
basePath := "../testdata/renderer-added-file/plugin"
runningWindows = true
s := ProvideService(&config.PluginManagementCfg{}, statickey.New())
s := provideDefaultTestService()
sig, err := s.Calculate(context.Background(), &fakes.FakePluginSource{
PluginClassFunc: func(ctx context.Context) plugins.Class {
return plugins.ClassExternal
@ -247,7 +255,7 @@ func TestCalculate(t *testing.T) {
toSlash = tc.platform.toSlashFunc()
fromSlash = tc.platform.fromSlashFunc()
s := ProvideService(&config.PluginManagementCfg{}, statickey.New())
s := provideDefaultTestService()
pfs, err := tc.fsFactory()
require.NoError(t, err)
pfs, err = newPathSeparatorOverrideFS(string(tc.platform.separator), pfs)
@ -721,7 +729,7 @@ func Test_validateManifest(t *testing.T) {
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
s := ProvideService(&config.PluginManagementCfg{}, statickey.New())
s := provideDefaultTestService()
err := s.validateManifest(context.Background(), *tc.manifest, nil)
require.Errorf(t, err, tc.expectedErr)
})

View File

@ -262,6 +262,7 @@ type PluginMetaDTO struct {
JSONData
Signature SignatureStatus `json:"signature"`
Module string `json:"module"`
ModuleHash string `json:"moduleHash,omitempty"`
BaseURL string `json:"baseUrl"`
Angular AngularMeta `json:"angular"`
MultiValueFilterOperators bool `json:"multiValueFilterOperators"`
@ -314,6 +315,7 @@ type PanelDTO struct {
Module string `json:"module"`
Angular AngularMeta `json:"angular"`
LoadingStrategy LoadingStrategy `json:"loadingStrategy"`
ModuleHash string `json:"moduleHash,omitempty"`
}
type AppDTO struct {
@ -325,6 +327,7 @@ type AppDTO struct {
LoadingStrategy LoadingStrategy `json:"loadingStrategy"`
Extensions Extensions `json:"extensions"`
Dependencies Dependencies `json:"dependencies"`
ModuleHash string `json:"moduleHash,omitempty"`
}
const (

View File

@ -1514,6 +1514,12 @@ var (
HideFromDocs: true,
HideFromAdminPage: true,
},
{
Name: "pluginsSriChecks",
Description: "Enables SRI checks for plugin assets",
Stage: FeatureStageExperimental,
Owner: grafanaPluginsPlatformSquad,
},
}
)

View File

@ -200,3 +200,4 @@ improvedExternalSessionHandling,experimental,@grafana/identity-access-team,false
useSessionStorageForRedirection,preview,@grafana/identity-access-team,false,false,false
rolePickerDrawer,experimental,@grafana/identity-access-team,false,false,false
unifiedStorageSearch,experimental,@grafana/search-and-storage,false,false,false
pluginsSriChecks,experimental,@grafana/plugins-platform-backend,false,false,false

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
200 useSessionStorageForRedirection preview @grafana/identity-access-team false false false
201 rolePickerDrawer experimental @grafana/identity-access-team false false false
202 unifiedStorageSearch experimental @grafana/search-and-storage false false false
203 pluginsSriChecks experimental @grafana/plugins-platform-backend false false false

View File

@ -810,4 +810,8 @@ const (
// FlagUnifiedStorageSearch
// Enable unified storage search
FlagUnifiedStorageSearch = "unifiedStorageSearch"
// FlagPluginsSriChecks
// Enables SRI checks for plugin assets
FlagPluginsSriChecks = "pluginsSriChecks"
)

View File

@ -2367,6 +2367,18 @@
"codeowner": "@grafana/plugins-platform-backend"
}
},
{
"metadata": {
"name": "pluginsSriChecks",
"resourceVersion": "1727785264632",
"creationTimestamp": "2024-10-01T12:21:04Z"
},
"spec": {
"description": "Enables SRI checks for plugin assets",
"stage": "experimental",
"codeowner": "@grafana/plugins-platform-backend"
}
},
{
"metadata": {
"name": "preserveDashboardStateWhenNavigating",

View File

@ -2,14 +2,21 @@ 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"
"github.com/grafana/grafana/pkg/setting"
)
const (
@ -21,18 +28,24 @@ var (
scriptLoadingMinSupportedVersion = semver.MustParse(CreatePluginVersionScriptSupportEnabled)
)
func ProvideService(cfg *setting.Cfg, cdn *pluginscdn.Service) *Service {
func ProvideService(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service, sig *signature.Signature, store pluginstore.Store) *Service {
return &Service{
cfg: cfg,
cdn: cdn,
log: log.New("pluginassets"),
cfg: cfg,
cdn: cdn,
signature: sig,
store: store,
log: log.New("pluginassets"),
}
}
type Service struct {
cfg *setting.Cfg
cdn *pluginscdn.Service
log log.Logger
cfg *config.PluginManagementCfg
cdn *pluginscdn.Service
signature *signature.Signature
store pluginstore.Store
log log.Logger
moduleHashCache sync.Map
}
// LoadingStrategy calculates the loading strategy for a plugin.
@ -69,6 +82,86 @@ 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.
// 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)
}
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)
@ -86,3 +179,17 @@ func (s *Service) compatibleCreatePluginVersion(ps map[string]string) bool {
func (s *Service) cdnEnabled(pluginID string, class plugins.Class) bool {
return s.cdn.PluginSupported(pluginID) || class == plugins.ClassCDN
}
// 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,13 +2,17 @@ package pluginassets
import (
"context"
"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/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"
"github.com/grafana/grafana/pkg/setting"
@ -34,7 +38,7 @@ func TestService_Calculate(t *testing.T) {
pluginSettings: newPluginSettings(pluginID, map[string]string{
CreatePluginVersionCfgKey: compatVersion,
}),
plugin: newPlugin(pluginID, false),
plugin: newPlugin(pluginID, withAngular(false)),
expected: plugins.LoadingStrategyScript,
},
{
@ -42,7 +46,7 @@ func TestService_Calculate(t *testing.T) {
pluginSettings: newPluginSettings("parent-datasource", map[string]string{
CreatePluginVersionCfgKey: compatVersion,
}),
plugin: newPlugin(pluginID, false, func(p pluginstore.Plugin) pluginstore.Plugin {
plugin: newPlugin(pluginID, withAngular(false), func(p pluginstore.Plugin) pluginstore.Plugin {
p.Parent = &pluginstore.ParentPlugin{ID: "parent-datasource"}
return p
}),
@ -53,7 +57,7 @@ func TestService_Calculate(t *testing.T) {
pluginSettings: newPluginSettings(pluginID, map[string]string{
CreatePluginVersionCfgKey: futureVersion,
}),
plugin: newPlugin(pluginID, false),
plugin: newPlugin(pluginID, withAngular(false)),
expected: plugins.LoadingStrategyScript,
},
{
@ -61,7 +65,7 @@ func TestService_Calculate(t *testing.T) {
pluginSettings: newPluginSettings(pluginID, map[string]string{
// NOTE: cdn key is not set
}),
plugin: newPlugin(pluginID, false),
plugin: newPlugin(pluginID, withAngular(false)),
expected: plugins.LoadingStrategyScript,
},
{
@ -70,7 +74,7 @@ func TestService_Calculate(t *testing.T) {
CreatePluginVersionCfgKey: incompatVersion,
// NOTE: cdn key is not set
}),
plugin: newPlugin(pluginID, false, func(p pluginstore.Plugin) pluginstore.Plugin {
plugin: newPlugin(pluginID, withAngular(false), func(p pluginstore.Plugin) pluginstore.Plugin {
p.Class = plugins.ClassExternal
return p
}),
@ -83,7 +87,7 @@ func TestService_Calculate(t *testing.T) {
"cdn": "true",
},
},
plugin: newPlugin(pluginID, false, func(p pluginstore.Plugin) pluginstore.Plugin {
plugin: newPlugin(pluginID, withAngular(false), func(p pluginstore.Plugin) pluginstore.Plugin {
p.Parent = &pluginstore.ParentPlugin{ID: "parent-datasource"}
return p
}),
@ -96,8 +100,7 @@ func TestService_Calculate(t *testing.T) {
"cdn": "true",
},
},
plugin: newPlugin(pluginID, false, func(p pluginstore.Plugin) pluginstore.Plugin {
p.Angular.Detected = true
plugin: newPlugin(pluginID, withAngular(true), func(p pluginstore.Plugin) pluginstore.Plugin {
p.Parent = &pluginstore.ParentPlugin{ID: "parent-datasource"}
return p
}),
@ -106,8 +109,7 @@ func TestService_Calculate(t *testing.T) {
{
name: "Expected LoadingStrategyFetch when parent create-plugin version is not set, is not configured as CDN enabled and plugin is angular",
pluginSettings: setting.PluginSettings{},
plugin: newPlugin(pluginID, false, func(p pluginstore.Plugin) pluginstore.Plugin {
p.Angular.Detected = true
plugin: newPlugin(pluginID, withAngular(true), func(p pluginstore.Plugin) pluginstore.Plugin {
p.Parent = &pluginstore.ParentPlugin{ID: "parent-datasource"}
return p
}),
@ -119,7 +121,7 @@ func TestService_Calculate(t *testing.T) {
"cdn": "true",
CreatePluginVersionCfgKey: incompatVersion,
}),
plugin: newPlugin(pluginID, false, func(p pluginstore.Plugin) pluginstore.Plugin {
plugin: newPlugin(pluginID, withAngular(false), func(p pluginstore.Plugin) pluginstore.Plugin {
p.Class = plugins.ClassExternal
return p
}),
@ -130,7 +132,7 @@ func TestService_Calculate(t *testing.T) {
pluginSettings: newPluginSettings(pluginID, map[string]string{
CreatePluginVersionCfgKey: incompatVersion,
}),
plugin: newPlugin(pluginID, true),
plugin: newPlugin(pluginID, withAngular(true)),
expected: plugins.LoadingStrategyFetch,
},
{
@ -139,7 +141,7 @@ func TestService_Calculate(t *testing.T) {
"cdn": "true",
CreatePluginVersionCfgKey: incompatVersion,
}),
plugin: newPlugin(pluginID, false),
plugin: newPlugin(pluginID, withAngular(false)),
expected: plugins.LoadingStrategyFetch,
},
{
@ -147,7 +149,7 @@ func TestService_Calculate(t *testing.T) {
pluginSettings: newPluginSettings(pluginID, map[string]string{
CreatePluginVersionCfgKey: incompatVersion,
}),
plugin: newPlugin(pluginID, false, func(p pluginstore.Plugin) pluginstore.Plugin {
plugin: newPlugin(pluginID, withAngular(false), func(p pluginstore.Plugin) pluginstore.Plugin {
p.Class = plugins.ClassCDN
return p
}),
@ -158,7 +160,7 @@ func TestService_Calculate(t *testing.T) {
pluginSettings: newPluginSettings(pluginID, map[string]string{
CreatePluginVersionCfgKey: "invalidSemver",
}),
plugin: newPlugin(pluginID, false),
plugin: newPlugin(pluginID, withAngular(false)),
expected: plugins.LoadingStrategyScript,
},
}
@ -179,12 +181,305 @@ func TestService_Calculate(t *testing.T) {
}
}
func newPlugin(pluginID string, angular bool, cbs ...func(p pluginstore.Plugin) pluginstore.Plugin) pluginstore.Plugin {
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
plugin pluginstore.Plugin
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: "",
},
{
name: "feature flag on with cdn on should return module hash",
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
),
cdn: true,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"),
},
{
name: "feature flag on with cdn off should return module hash",
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
),
cdn: false,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"),
},
{
name: "feature flag off with cdn on should not return module hash",
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
),
cdn: true,
features: &config.Features{SriChecksEnabled: false},
expModuleHash: "",
},
{
name: "feature flag off with cdn off should not return module hash",
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: false,
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: false,
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: false,
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: "",
},
} {
t.Run(tc.name, func(t *testing.T) {
var pluginSettings setting.PluginSettings
if tc.cdn {
pluginSettings = newPluginSettings(pluginID, 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: setting.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"))),
)
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{
ID: pluginID,
},
Angular: plugins.AngularMeta{Detected: angular},
}
for _, cb := range cbs {
p = cb(p)
@ -192,8 +487,43 @@ func newPlugin(pluginID string, angular bool, cbs ...func(p pluginstore.Plugin)
return p
}
func newCfg(ps setting.PluginSettings) *setting.Cfg {
return &setting.Cfg{
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
return p
}
}
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}
return p
}
}
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 newCfg(ps setting.PluginSettings) *config.PluginManagementCfg {
return &config.PluginManagementCfg{
PluginSettings: ps,
}
}
@ -203,3 +533,9 @@ func newPluginSettings(pluginID string, kv map[string]string) setting.PluginSett
pluginID: kv,
}
}
func newSRIHash(t *testing.T, s string) string {
r, err := convertHashForSRI(s)
require.NoError(t, err)
return r
}

View File

@ -0,0 +1 @@
hello parent

View File

@ -0,0 +1,15 @@
{
"type": "app",
"name": "Test",
"id": "test-app",
"backend": true,
"executable": "test",
"state": "alpha",
"info": {
"version": "1.0.0",
"description": "Test",
"author": {
"name": "Giuseppe Guerra"
}
}
}

View File

@ -0,0 +1,29 @@
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512
{
"manifestVersion": "2.0.0",
"signatureType": "grafana",
"signedByOrg": "grafana",
"signedByOrgName": "Grafana Labs",
"plugin": "test-app",
"version": "1.0.0",
"time": 1726230812215,
"keyId": "7e4d0c6a708866e7",
"files": {
"plugin.json": "31f04aceb2a9b14c2e501f38a4de5ab1c7a3e7306f58353fa5c1a86b716c971c",
"something.js": "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a"
}
}
-----BEGIN PGP SIGNATURE-----
Version: OpenPGP.js v4.10.11
Comment: https://openpgpjs.org
wrkEARMKAAYFAmbkMRwAIQkQfk0ManCIZucWIQTzOyW2kQdOhGNlcPN+TQxq
cIhm53UWAgkBE2oxqyzBji86eCOzLmCT7IgQaoSMMF48tu+XdgwFS5/NU5su
deKad3taDnSU9a7GkCaisRVQOWy/UtFS1FNQTtkCCQBc1cZ6JsPWh2Pd60h0
9U5aviYde6g1DCKO1riaUzHzrruBiHmHWjzr2aYwACb89vs2XcZqvue1Byb+
y2inBDhHvQ==
=qMej
-----END PGP SIGNATURE-----

View File

@ -0,0 +1,15 @@
{
"type": "app",
"name": "Test",
"id": "test-app",
"backend": true,
"executable": "test",
"state": "alpha",
"info": {
"version": "1.0.0",
"description": "Test",
"author": {
"name": "Giuseppe Guerra"
}
}
}

View File

@ -0,0 +1 @@
hello parent

View File

@ -0,0 +1,33 @@
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512
{
"manifestVersion": "2.0.0",
"signatureType": "grafana",
"signedByOrg": "grafana",
"signedByOrgName": "Grafana Labs",
"plugin": "test-app",
"version": "1.0.0",
"time": 1726234125061,
"keyId": "7e4d0c6a708866e7",
"files": {
"datasource/module.js": "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711",
"datasource/plugin.json": "3fd712717a21617cc76f9043efcd43d4ebf5564dd155a28e4e3c736739f6931e",
"datasource/panels/one/module.js": "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f",
"datasource/panels/one/plugin.json": "b9b4556a7220ea77650ffd228da6d441e68df3405d50dab5773c10f4afae5ad3",
"module.js": "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a",
"plugin.json": "31f04aceb2a9b14c2e501f38a4de5ab1c7a3e7306f58353fa5c1a86b716c971c"
}
}
-----BEGIN PGP SIGNATURE-----
Version: OpenPGP.js v4.10.11
Comment: https://openpgpjs.org
wrkEARMKAAYFAmbkPg0AIQkQfk0ManCIZucWIQTzOyW2kQdOhGNlcPN+TQxq
cIhm5xTlAgkB3mG37KEdlP34nC69NbmriMpDH6PyyJ0IUwXB/SMTr4Gc2SvG
cVHvih/0WqVjYKxxQI0QHoYpBQW2jPx0YJLFof8CCQBHpdEEXNTYOOZWG6Cg
M3wB3AdCO+ChjXkKosbWqiMDfVqHFoLoLurwWxwOjvk/xTvX5GFbOxSfISyU
8iW03F5/Sw==
=wobV
-----END PGP SIGNATURE-----

View File

@ -0,0 +1,13 @@
{
"type": "panel",
"name": "Test Panel",
"id": "test-panel",
"state": "alpha",
"info": {
"version": "1.0.0",
"description": "Test",
"author": {
"name": "Giuseppe Guerra"
}
}
}

View File

@ -0,0 +1,15 @@
{
"type": "datasource",
"name": "Test Datasource",
"id": "test-datasource",
"backend": true,
"executable": "test",
"state": "alpha",
"info": {
"version": "1.0.0",
"description": "Test",
"author": {
"name": "Giuseppe Guerra"
}
}
}

View File

@ -0,0 +1,15 @@
{
"type": "app",
"name": "Test",
"id": "test-app",
"backend": true,
"executable": "test",
"state": "alpha",
"info": {
"version": "1.0.0",
"description": "Test",
"author": {
"name": "Giuseppe Guerra"
}
}
}

View File

@ -0,0 +1,33 @@
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512
{
"manifestVersion": "2.0.0",
"signatureType": "grafana",
"signedByOrg": "grafana",
"signedByOrgName": "Grafana Labs",
"plugin": "test-app",
"version": "1.0.0",
"time": 1726230803822,
"keyId": "7e4d0c6a708866e7",
"files": {
"module.js": "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a",
"plugin.json": "31f04aceb2a9b14c2e501f38a4de5ab1c7a3e7306f58353fa5c1a86b716c971c",
"datasource/module.js": "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711",
"datasource/plugin.json": "3fd712717a21617cc76f9043efcd43d4ebf5564dd155a28e4e3c736739f6931e",
"panels/one/module.js": "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f",
"panels/one/plugin.json": "b9b4556a7220ea77650ffd228da6d441e68df3405d50dab5773c10f4afae5ad3"
}
}
-----BEGIN PGP SIGNATURE-----
Version: OpenPGP.js v4.10.11
Comment: https://openpgpjs.org
wrkEARMKAAYFAmbkMRQAIQkQfk0ManCIZucWIQTzOyW2kQdOhGNlcPN+TQxq
cIhm50C8AgkAmzQpeYPnCgYimLGp5UGnCTrkbUEEqW+qXESrhi5T5ZuM+SzT
BcRlC5pP6+wuyXAIdfppzWQ/umkkoaTIuub0TXQCCQHVcpWKy4acRL9TlORQ
1VzVEV9PW0+x606HsDDHkterKQZgr5X6I/sTbSpBDMWPCMxqAk9fZn3G4iuq
MyS+hwUZDQ==
=7/Rd
-----END PGP SIGNATURE-----

View File

@ -0,0 +1 @@
hello datasource

View File

@ -0,0 +1,15 @@
{
"type": "datasource",
"name": "Test Datasource",
"id": "test-datasource",
"backend": true,
"executable": "test",
"state": "alpha",
"info": {
"version": "1.0.0",
"description": "Test",
"author": {
"name": "Giuseppe Guerra"
}
}
}

View File

@ -0,0 +1 @@
hello parent

View File

@ -0,0 +1,13 @@
{
"type": "panel",
"name": "Test Panel",
"id": "test-panel",
"state": "alpha",
"info": {
"version": "1.0.0",
"description": "Test",
"author": {
"name": "Giuseppe Guerra"
}
}
}

View File

@ -0,0 +1,15 @@
{
"type": "app",
"name": "Test",
"id": "test-app",
"backend": true,
"executable": "test",
"state": "alpha",
"info": {
"version": "1.0.0",
"description": "Test",
"author": {
"name": "Giuseppe Guerra"
}
}
}

View File

@ -0,0 +1,32 @@
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512
{
"manifestVersion": "2.0.0",
"signatureType": "private",
"signedByOrg": "giuseppeguerra",
"signedByOrgName": "giuseppeguerra",
"rootUrls": [
"http://127.0.0.1:3000/"
],
"plugin": "test-datasource",
"version": "1.0.0",
"time": 1725959570435,
"keyId": "7e4d0c6a708866e7",
"files": {
"module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03",
"plugin.json": "129fab4e0584d18c778ebdfa5fe1a68edf2e5c5aeb8290b2c68182c857cb59f8"
}
}
-----BEGIN PGP SIGNATURE-----
Version: OpenPGP.js v4.10.11
Comment: https://openpgpjs.org
wrkEARMKAAYFAmbgDZIAIQkQfk0ManCIZucWIQTzOyW2kQdOhGNlcPN+TQxq
cIhm5wbfAgkAXmKJcM8uAKb3TepYW/oyGhRLR8L6eM9mCoYwKkatITKJ6bRe
Wnz37AMcPx0DahgfCzCXRLo4CspPJylr2JV8DagCCQCfCjHgLFhKGpBP71Y1
mgcQ1/CJefb6B2H45G25MwUFTlSTGLDqW4QMi2kQvXnnUMjXquv2+iVd6qyz
0Rqvpou/QQ==
=QNmr
-----END PGP SIGNATURE-----

View File

@ -0,0 +1 @@
hello

View File

@ -0,0 +1,15 @@
{
"type": "datasource",
"name": "Test",
"id": "test-datasource",
"backend": true,
"executable": "test",
"state": "alpha",
"info": {
"version": "1.0.0",
"description": "Test",
"author": {
"name": "Giuseppe Guerra"
}
}
}

View File

@ -32,6 +32,7 @@ func ProvidePluginManagementConfig(cfg *setting.Cfg, settingProvider setting.Pro
config.Features{
ExternalCorePluginsEnabled: features.IsEnabledGlobally(featuremgmt.FlagExternalCorePlugins),
SkipHostEnvVarsEnabled: features.IsEnabledGlobally(featuremgmt.FlagPluginsSkipHostEnvVars),
SriChecksEnabled: features.IsEnabledGlobally(featuremgmt.FlagPluginsSriChecks),
},
cfg.AngularSupportEnabled,
cfg.GrafanaComAPIURL,

View File

@ -10,7 +10,7 @@ import (
type Plugin struct {
plugins.JSONData
fs plugins.FS
FS plugins.FS
supportsStreaming bool
Class plugins.Class
@ -42,7 +42,7 @@ func (p Plugin) SupportsStreaming() bool {
}
func (p Plugin) Base() string {
return p.fs.Base()
return p.FS.Base()
}
func (p Plugin) IsApp() bool {
@ -61,7 +61,7 @@ func ToGrafanaDTO(p *plugins.Plugin) Plugin {
}
dto := Plugin{
fs: p.FS,
FS: p.FS,
supportsStreaming: supportsStreaming,
Class: p.Class,
JSONData: p.JSONData,

View File

@ -63,6 +63,7 @@ function getPanelPlugin(meta: PanelPluginMeta): Promise<PanelPlugin> {
isAngular: meta.angular?.detected,
loadingStrategy: fallbackLoadingStrategy,
pluginId: meta.id,
moduleHash: meta.moduleHash,
})
.then((pluginExports) => {
if (pluginExports.plugin) {

View File

@ -57,6 +57,7 @@ async function preload(config: AppPluginConfig): Promise<PluginPreloadResult> {
isAngular: config.angular.detected,
pluginId,
loadingStrategy,
moduleHash: config.moduleHash,
});
const { exposedComponentConfigs = [], addedComponentConfigs = [], addedLinkConfigs = [] } = plugin;

View File

@ -7,6 +7,7 @@ import {
PluginLoadingStrategy,
PluginMeta,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
import { GenericDataSourcePlugin } from '../datasources/types';
@ -73,12 +74,14 @@ export async function importPluginModule({
loadingStrategy,
version,
isAngular,
moduleHash,
}: {
path: string;
pluginId: string;
loadingStrategy: PluginLoadingStrategy;
version?: string;
isAngular?: boolean;
moduleHash?: string;
}): Promise<System.Module> {
if (version) {
registerPluginInCache({ path, version, loadingStrategy });
@ -94,7 +97,21 @@ export async function importPluginModule({
}
}
let modulePath = resolveModulePath(path);
const modulePath = resolveModulePath(path);
// inject integrity hash into SystemJS import map
if (config.featureToggles.pluginsSriChecks) {
const resolvedModule = System.resolve(modulePath);
const integrityMap = System.getImportMap().integrity;
if (moduleHash && integrityMap && !integrityMap[resolvedModule]) {
SystemJS.addImportMap({
integrity: {
[resolvedModule]: moduleHash,
},
});
}
}
// the sandboxing environment code cannot work in nodejs and requires a real browser
if (await isFrontendSandboxSupported({ isAngular, pluginId })) {
@ -113,6 +130,7 @@ export function importDataSourcePlugin(meta: DataSourcePluginMeta): Promise<Gene
isAngular,
loadingStrategy: fallbackLoadingStrategy,
pluginId: meta.id,
moduleHash: meta.moduleHash,
}).then((pluginExports) => {
if (pluginExports.plugin) {
const dsPlugin: GenericDataSourcePlugin = pluginExports.plugin;
@ -144,6 +162,7 @@ export function importAppPlugin(meta: PluginMeta): Promise<AppPlugin> {
isAngular,
loadingStrategy: fallbackLoadingStrategy,
pluginId: meta.id,
moduleHash: meta.moduleHash,
}).then((pluginExports) => {
const plugin: AppPlugin = pluginExports.plugin ? pluginExports.plugin : new AppPlugin();
plugin.init(meta);