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
co-authored by Jack Westbrook
parent 153036be2e
commit 0db65d229e
46 changed files with 901 additions and 104 deletions
+1
View File
@@ -32,6 +32,7 @@ type PluginManagementCfg struct {
type Features struct {
ExternalCorePluginsEnabled bool
SkipHostEnvVarsEnabled bool
SriChecksEnabled bool
}
// NewPluginManagementCfg returns a new PluginManagementCfg.
+41 -32
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"}
}
+21 -13
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)
})
+3
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 (