From f248a555764d38d1cf17a52c51fd95d9882bbea7 Mon Sep 17 00:00:00 2001 From: Esteban Beltran Date: Thu, 17 Oct 2024 16:56:50 +0200 Subject: [PATCH] Frontend Sandbox: Create a plugin sandbox enable registry. Use enable list instead of disable list (#94809) * Use a enable configuration to enable frontend sandbox * Modify settings to load enableFrontendSandbox * Check for signature type * Update commment * Fix e2e tests for the frontend sandbox * Modify logic so a custom check function is used instead of a list of checks * Fixes flaky test * fix comment * Update comment * Empty commit * Empty commit --- conf/defaults.ini | 5 +- conf/sample.ini | 5 +- .../setup-grafana/configure-grafana/_index.md | 5 +- .../frontend-sandbox-panel.spec.ts | 128 --------------- .../frontend-sandbox-app.spec.ts | 71 -------- .../frontend-sandbox-datasource.spec.ts | 155 ------------------ .../frontend-sandbox-panel.spec.ts | 11 +- .../frontend-sandbox-datasource.spec.ts | 2 +- packages/grafana-runtime/src/config.ts | 2 +- pkg/api/dtos/frontend_settings.go | 2 +- pkg/api/frontendsettings.go | 2 +- pkg/setting/setting.go | 18 +- public/app/features/plugins/plugin_loader.ts | 4 +- .../sandbox_plugin_loader_registry.test.ts | 92 +++++++++++ .../sandbox/sandbox_plugin_loader_registry.ts | 78 +++++++++ public/app/features/plugins/sandbox/utils.ts | 46 ------ scripts/grafana-server/custom.ini | 1 + 17 files changed, 203 insertions(+), 424 deletions(-) delete mode 100644 e2e/old-arch/panels-suite/frontend-sandbox-panel.spec.ts delete mode 100644 e2e/old-arch/various-suite/frontend-sandbox-app.spec.ts delete mode 100644 e2e/old-arch/various-suite/frontend-sandbox-datasource.spec.ts create mode 100644 public/app/features/plugins/sandbox/sandbox_plugin_loader_registry.test.ts create mode 100644 public/app/features/plugins/sandbox/sandbox_plugin_loader_registry.ts diff --git a/conf/defaults.ini b/conf/defaults.ini index b52599aab85..b61cc964765 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -403,8 +403,9 @@ angular_support_enabled = false # The CSRF check will be executed even if the request has no login cookie. csrf_always_check = false -# Comma-separated list of plugins ids that won't be loaded inside the frontend sandbox -disable_frontend_sandbox_for_plugins = grafana-incident-app +# Comma-separated list of plugins ids that will be loaded inside the frontend sandbox +# Currently behind the feature flag pluginsFrontendSandbox +enable_frontend_sandbox_for_plugins = # Comma-separated list of paths for POST/PUT URL in actions. Empty will allow anything that is not on the same origin actions_allow_post_url = diff --git a/conf/sample.ini b/conf/sample.ini index 366b04d884a..e22e4690f4f 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -408,8 +408,9 @@ # The CSRF check will be executed even if the request has no login cookie. ;csrf_always_check = false -# Comma-separated list of plugins ids that won't be loaded inside the frontend sandbox -;disable_frontend_sandbox_for_plugins = +# Comma-separated list of plugins ids that will be loaded inside the frontend sandbox +# Currently behind the feature flag pluginsFrontendSandbox +;enable_frontend_sandbox_for_plugins = # Comma-separated list of paths for POST/PUT URL in actions. Empty will allow anything that is not on the same origin ;actions_allow_post_url = diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index e551b21d949..f99243a5697 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -729,10 +729,9 @@ List of allowed headers to be set by the user. Suggested to use for if authentic Set to `true` to execute the CSRF check even if the login cookie is not in a request (default `false`). -### disable_frontend_sandbox_for_plugins +### enable_frontend_sandbox_for_plugins -Comma-separated list of plugins ids that won't be loaded inside the frontend sandbox. It is recommended to only use this -option for plugins that are known to have problems running inside the frontend sandbox. +Comma-separated list of plugins ids that will be loaded inside the frontend sandbox. ## [snapshots] diff --git a/e2e/old-arch/panels-suite/frontend-sandbox-panel.spec.ts b/e2e/old-arch/panels-suite/frontend-sandbox-panel.spec.ts deleted file mode 100644 index bab59b99866..00000000000 --- a/e2e/old-arch/panels-suite/frontend-sandbox-panel.spec.ts +++ /dev/null @@ -1,128 +0,0 @@ -import panelSandboxDashboard from '../dashboards/PanelSandboxDashboard.json'; -import { e2e } from '../utils'; - -const DASHBOARD_ID = 'c46b2460-16b7-42a5-82d1-b07fbf431950'; - -describe('Panel sandbox', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true); - return e2e.flows.importDashboard(panelSandboxDashboard, 1000, true); - }); - - describe('Sandbox disabled', () => { - beforeEach(() => { - cy.window().then((win) => { - win.localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=0'); - }); - cy.reload(); - }); - - it('Add iframes to body', () => { - // this button adds iframes to the body - cy.get('[data-testid="button-create-iframes"]').click(); - - const iframeIds = [ - 'createElementIframe', - 'innerHTMLIframe', - 'appendIframe', - 'prependIframe', - 'afterIframe', - 'beforeIframe', - 'outerHTMLIframe', - 'parseFromStringIframe', - 'insertBeforeIframe', - 'replaceChildIframe', - ]; - iframeIds.forEach((id) => { - cy.get(`#${id}`).should('exist'); - }); - }); - - it('Reaches out of panel div', () => { - // this button reaches out of the panel div and modifies the element dataset - cy.get('[data-testid="button-reach-out"]').click(); - - cy.get('[data-sandbox-test="true"]').should('exist'); - }); - - it('Reaches out of the panel editor', () => { - e2e.flows.openDashboard({ - uid: DASHBOARD_ID, - queryParams: { - editPanel: 1, - }, - }); - - cy.get('[data-testid="panel-editor-custom-editor-input"]').should('not.be.disabled'); - cy.get('[data-testid="panel-editor-custom-editor-input"]').type('x', { force: true }); - cy.get('[data-sandbox-test="panel-editor"]').should('exist'); - }); - }); - - describe('Sandbox enabled', () => { - beforeEach(() => { - cy.window().then((win) => { - win.localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=1'); - }); - cy.reload(); - }); - - it('Does not add iframes to body', () => { - // this button adds 3 iframes to the body - cy.get('[data-testid="button-create-iframes"]').click(); - cy.wait(100); // small delay to prevent false positives from too fast tests - - const iframeIds = [ - 'createElementIframe', - 'innerHTMLIframe', - 'appendIframe', - 'prependIframe', - 'afterIframe', - 'beforeIframe', - 'outerHTMLIframe', - 'parseFromStringIframe', - 'insertBeforeIframe', - 'replaceChildIframe', - ]; - iframeIds.forEach((id) => { - cy.get(`#${id}`).should('not.exist'); - }); - }); - - it('Does not reaches out of panel div', () => { - // this button reaches out of the panel div and modifies the element dataset - cy.get('[data-testid="button-reach-out"]').click(); - cy.wait(100); // small delay to prevent false positives from too fast tests - cy.get('[data-sandbox-test="true"]').should('not.exist'); - }); - - it('Does not Reaches out of the panel editor', () => { - e2e.flows.openDashboard({ - uid: DASHBOARD_ID, - queryParams: { - editPanel: 1, - }, - }); - - cy.get('[data-testid="panel-editor-custom-editor-input"]').should('not.be.disabled'); - cy.get('[data-testid="panel-editor-custom-editor-input"]').type('x', { force: true }); - cy.wait(100); // small delay to prevent false positives from too fast tests - cy.get('[data-sandbox-test="panel-editor"]').should('not.exist'); - }); - - it('Can access specific window global variables', () => { - cy.get('[data-testid="button-test-globals"]').click(); - cy.get('[data-sandbox-global="Prism"]').should('be.visible'); - cy.get('[data-sandbox-global="jQuery"]').should('be.visible'); - cy.get('[data-sandbox-global="location"]').should('be.visible'); - }); - }); - - afterEach(() => { - e2e.flows.revertAllChanges(); - }); - - after(() => { - return cy.clearCookies(); - }); -}); diff --git a/e2e/old-arch/various-suite/frontend-sandbox-app.spec.ts b/e2e/old-arch/various-suite/frontend-sandbox-app.spec.ts deleted file mode 100644 index b3e17ece6a9..00000000000 --- a/e2e/old-arch/various-suite/frontend-sandbox-app.spec.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { e2e } from '../utils'; - -const APP_ID = 'sandbox-app-test'; - -describe('Datasource sandbox', () => { - before(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true); - cy.request({ - url: `${Cypress.env('BASE_URL')}/api/plugins/${APP_ID}/settings`, - method: 'POST', - body: { - enabled: true, - }, - }); - }); - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true); - }); - - describe('App Page', () => { - describe('Sandbox disabled', () => { - beforeEach(() => { - cy.window().then((win) => { - win.localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=0'); - }); - }); - - it('Loads the app page without the sandbox div wrapper', () => { - cy.visit(`/a/${APP_ID}`); - cy.wait(200); // wait to prevent false positives because cypress checks too fast - cy.get('div[data-plugin-sandbox="sandbox-app-test"]').should('not.exist'); - cy.get('div[data-testid="sandbox-app-test-page-one"]').should('exist'); - }); - - it('Loads the app configuration without the sandbox div wrapper', () => { - cy.visit(`/plugins/${APP_ID}`); - cy.wait(200); // wait to prevent false positives because cypress checks too fast - cy.get('div[data-plugin-sandbox="sandbox-app-test"]').should('not.exist'); - cy.get('div[data-testid="sandbox-app-test-config-page"]').should('exist'); - }); - }); - - describe('Sandbox enabled', () => { - beforeEach(() => { - cy.window().then((win) => { - win.localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=1'); - }); - }); - - it('Loads the app page with the sandbox div wrapper', () => { - cy.visit(`/a/${APP_ID}`); - cy.get('div[data-plugin-sandbox="sandbox-app-test"]').should('exist'); - cy.get('div[data-testid="sandbox-app-test-page-one"]').should('exist'); - }); - - it('Loads the app configuration with the sandbox div wrapper', () => { - cy.visit(`/plugins/${APP_ID}`); - cy.get('div[data-plugin-sandbox="sandbox-app-test"]').should('exist'); - cy.get('div[data-testid="sandbox-app-test-config-page"]').should('exist'); - }); - }); - }); - - afterEach(() => { - e2e.flows.revertAllChanges(); - }); - - after(() => { - cy.clearCookies(); - }); -}); diff --git a/e2e/old-arch/various-suite/frontend-sandbox-datasource.spec.ts b/e2e/old-arch/various-suite/frontend-sandbox-datasource.spec.ts deleted file mode 100644 index bfc3fd3fdc3..00000000000 --- a/e2e/old-arch/various-suite/frontend-sandbox-datasource.spec.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { random } from 'lodash'; - -import { e2e } from '../utils'; - -const DATASOURCE_ID = 'sandbox-test-datasource'; -let DATASOURCE_CONNECTION_ID = ''; -const DATASOURCE_TYPED_NAME = 'SandboxDatasourceInstance'; - -describe('Datasource sandbox', () => { - before(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true); - - e2e.pages.AddDataSource.visit(); - e2e.pages.AddDataSource.dataSourcePluginsV2('Sandbox datasource test plugin') - .scrollIntoView() - .should('be.visible') // prevents flakiness - .click(); - e2e.pages.DataSource.name().clear(); - e2e.pages.DataSource.name().type(DATASOURCE_TYPED_NAME); - e2e.pages.DataSource.saveAndTest().click(); - cy.url().then((url) => { - const split = url.split('/'); - DATASOURCE_CONNECTION_ID = split[split.length - 1]; - }); - }); - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true); - }); - - describe('Config Editor', () => { - describe('Sandbox disabled', () => { - beforeEach(() => { - cy.window().then((win) => { - win.localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=0'); - }); - }); - it('Should not render a sandbox wrapper around the datasource config editor', () => { - e2e.pages.EditDataSource.visit(DATASOURCE_CONNECTION_ID); - cy.wait(300); // wait to prevent false positives because cypress checks too fast - cy.get(`div[data-plugin-sandbox="${DATASOURCE_ID}"]`).should('not.exist'); - }); - }); - - describe('Sandbox enabled', () => { - beforeEach(() => { - cy.window().then((win) => { - win.localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=1'); - }); - }); - - it('Should render a sandbox wrapper around the datasource config editor', () => { - e2e.pages.EditDataSource.visit(DATASOURCE_CONNECTION_ID); - cy.get(`div[data-plugin-sandbox="${DATASOURCE_ID}"]`).should('exist'); - }); - - it('Should store values in jsonData and secureJsonData correctly', () => { - e2e.pages.EditDataSource.visit(DATASOURCE_CONNECTION_ID); - - const valueToStore = 'test' + random(100); - - cy.get('[data-testid="sandbox-config-editor-query-input"]').should('not.be.disabled'); - cy.get('[data-testid="sandbox-config-editor-query-input"]').type(valueToStore); - cy.get('[data-testid="sandbox-config-editor-query-input"]').should('have.value', valueToStore); - - e2e.pages.DataSource.saveAndTest().click(); - e2e.pages.DataSource.alert().should('exist').contains('Sandbox Success', {}); - - // validate the value was stored - e2e.pages.EditDataSource.visit(DATASOURCE_CONNECTION_ID); - cy.get('[data-testid="sandbox-config-editor-query-input"]').should('not.be.disabled'); - cy.get('[data-testid="sandbox-config-editor-query-input"]').should('have.value', valueToStore); - }); - }); - }); - - describe('Explore Page', () => { - describe('Sandbox disabled', () => { - beforeEach(() => { - cy.window().then((win) => { - win.localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=0'); - }); - }); - - it('Should not wrap the query editor in a sandbox wrapper', () => { - e2e.pages.Explore.visit(); - e2e.components.DataSourcePicker.container().should('be.visible').click(); - cy.contains(DATASOURCE_TYPED_NAME).scrollIntoView().should('be.visible').click(); - // make sure the datasource was correctly selected and rendered - e2e.components.Breadcrumbs.breadcrumb(DATASOURCE_TYPED_NAME).should('be.visible'); - - cy.wait(300); // wait to prevent false positives because cypress checks too fast - cy.get(`div[data-plugin-sandbox="${DATASOURCE_ID}"]`).should('not.exist'); - }); - - it('Should accept values when typed', () => { - e2e.pages.Explore.visit(); - e2e.components.DataSourcePicker.container().should('be.visible').click(); - cy.contains(DATASOURCE_TYPED_NAME).scrollIntoView().should('be.visible').click(); - - // make sure the datasource was correctly selected and rendered - e2e.components.Breadcrumbs.breadcrumb(DATASOURCE_TYPED_NAME).should('be.visible'); - - const valueToType = 'test' + random(100); - - cy.get('[data-testid="sandbox-query-editor-query-input"]').should('not.be.disabled'); - cy.get('[data-testid="sandbox-query-editor-query-input"]').type(valueToType); - cy.get('[data-testid="sandbox-query-editor-query-input"]').should('have.value', valueToType); - }); - }); - - describe('Sandbox enabled', () => { - beforeEach(() => { - cy.window().then((win) => { - win.localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=1'); - }); - }); - - it('Should wrap the query editor in a sandbox wrapper', () => { - e2e.pages.Explore.visit(); - e2e.components.DataSourcePicker.container().should('be.visible').click(); - cy.contains(DATASOURCE_TYPED_NAME).scrollIntoView().should('be.visible').click(); - // make sure the datasource was correctly selected and rendered - e2e.components.Breadcrumbs.breadcrumb(DATASOURCE_TYPED_NAME).should('be.visible'); - - cy.get(`div[data-plugin-sandbox="${DATASOURCE_ID}"]`).should('exist'); - }); - - it('Should accept values when typed', () => { - e2e.pages.Explore.visit(); - e2e.components.DataSourcePicker.container().should('be.visible').click(); - cy.contains(DATASOURCE_TYPED_NAME).scrollIntoView().should('be.visible').click(); - - // make sure the datasource was correctly selected and rendered - e2e.components.Breadcrumbs.breadcrumb(DATASOURCE_TYPED_NAME).should('be.visible'); - - const valueToType = 'test' + random(100); - - cy.get('[data-testid="sandbox-query-editor-query-input"]').should('not.be.disabled'); - cy.get('[data-testid="sandbox-query-editor-query-input"]').type(valueToType); - cy.get('[data-testid="sandbox-query-editor-query-input"]').should('have.value', valueToType); - - // typing the query editor should reflect in the url - cy.url().should('include', valueToType); - }); - }); - }); - - afterEach(() => { - e2e.flows.revertAllChanges(); - }); - - after(() => { - cy.clearCookies(); - }); -}); diff --git a/e2e/panels-suite/frontend-sandbox-panel.spec.ts b/e2e/panels-suite/frontend-sandbox-panel.spec.ts index b551617b705..4f2f0e3a192 100644 --- a/e2e/panels-suite/frontend-sandbox-panel.spec.ts +++ b/e2e/panels-suite/frontend-sandbox-panel.spec.ts @@ -3,7 +3,7 @@ import { e2e } from '../utils'; const DASHBOARD_ID = 'c46b2460-16b7-42a5-82d1-b07fbf431950'; // Skipping due to race conditions with same old arch test e2e/panels-suite/frontend-sandbox-panel.spec.ts -describe.skip('Panel sandbox', () => { +describe('Panel sandbox', () => { beforeEach(() => { e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true); return e2e.flows.importDashboard(panelSandboxDashboard, 1000, true); @@ -54,7 +54,12 @@ describe.skip('Panel sandbox', () => { }); cy.get('[data-testid="panel-editor-custom-editor-input"]').should('not.be.disabled'); - cy.get('[data-testid="panel-editor-custom-editor-input"]').type('x', { force: true }); + cy.get('[data-testid="panel-editor-custom-editor-input"]').should('have.value', ''); + // wait because sometimes cypress is faster than react and the value doesn't change + cy.wait(1000); + cy.get('[data-testid="panel-editor-custom-editor-input"]').type('x', { force: true, delay: 500 }); + cy.wait(100); // small delay to prevent false positives from too fast tests + cy.get('[data-testid="panel-editor-custom-editor-input"]').should('have.value', 'x'); cy.get('[data-sandbox-test="panel-editor"]').should('exist'); }); }); @@ -105,6 +110,8 @@ describe.skip('Panel sandbox', () => { }); cy.get('[data-testid="panel-editor-custom-editor-input"]').should('not.be.disabled'); + // wait because sometimes cypress is faster than react and the value doesn't change + cy.wait(1000); cy.get('[data-testid="panel-editor-custom-editor-input"]').type('x', { force: true }); cy.wait(100); // small delay to prevent false positives from too fast tests cy.get('[data-sandbox-test="panel-editor"]').should('not.exist'); diff --git a/e2e/various-suite/frontend-sandbox-datasource.spec.ts b/e2e/various-suite/frontend-sandbox-datasource.spec.ts index 651ba170db9..5525abf4993 100644 --- a/e2e/various-suite/frontend-sandbox-datasource.spec.ts +++ b/e2e/various-suite/frontend-sandbox-datasource.spec.ts @@ -7,7 +7,7 @@ let DATASOURCE_CONNECTION_ID = ''; const DATASOURCE_TYPED_NAME = 'SandboxDatasourceInstance'; // Skipping due to flakiness/race conditions with same old arch test e2e/various-suite/frontend-sandbox-datasource.spec.ts -describe.skip('Datasource sandbox', () => { +describe('Datasource sandbox', () => { before(() => { e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true); diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index c7c00a37c3b..6b751d0e62c 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -189,7 +189,7 @@ export class GrafanaBootConfig implements GrafanaConfig { }; tokenExpirationDayLimit: undefined; - disableFrontendSandboxForPlugins: string[] = []; + enableFrontendSandboxForPlugins: string[] = []; sharedWithMeFolderUID: string | undefined; rootFolderUID: string | undefined; localFileSystemAvailable: boolean | undefined; diff --git a/pkg/api/dtos/frontend_settings.go b/pkg/api/dtos/frontend_settings.go index c93e00cea0f..c0900e8ab47 100644 --- a/pkg/api/dtos/frontend_settings.go +++ b/pkg/api/dtos/frontend_settings.go @@ -203,7 +203,7 @@ type FrontendSettingsDTO struct { DisableSanitizeHtml bool `json:"disableSanitizeHtml"` TrustedTypesDefaultPolicyEnabled bool `json:"trustedTypesDefaultPolicyEnabled"` CSPReportOnlyEnabled bool `json:"cspReportOnlyEnabled"` - DisableFrontendSandboxForPlugins []string `json:"disableFrontendSandboxForPlugins"` + EnableFrontendSandboxForPlugins []string `json:"enableFrontendSandboxForPlugins"` ExploreDefaultTimeOffset string `json:"exploreDefaultTimeOffset"` Auth FrontendSettingsAuthDTO `json:"auth"` diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index e6449361268..2f09a859356 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -231,7 +231,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro CSPReportOnlyEnabled: hs.Cfg.CSPReportOnlyEnabled, DateFormats: hs.Cfg.DateFormats, SecureSocksDSProxyEnabled: hs.Cfg.SecureSocksDSProxy.Enabled && hs.Cfg.SecureSocksDSProxy.ShowUI, - DisableFrontendSandboxForPlugins: hs.Cfg.DisableFrontendSandboxForPlugins, + EnableFrontendSandboxForPlugins: hs.Cfg.EnableFrontendSandboxForPlugins, PublicDashboardAccessToken: c.PublicDashboardAccessToken, PublicDashboardsEnabled: hs.Cfg.PublicDashboardsEnabled, CloudMigrationIsTarget: isCloudMigrationTarget, diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 935254f907d..3306d4f9c62 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -175,12 +175,12 @@ type Cfg struct { // CSPReportEnabled toggles Content Security Policy Report Only support. CSPReportOnlyEnabled bool // CSPReportOnlyTemplate contains the Content Security Policy Report Only template. - CSPReportOnlyTemplate string - AngularSupportEnabled bool - DisableFrontendSandboxForPlugins []string - DisableGravatar bool - DataProxyWhiteList map[string]bool - ActionsAllowPostURL string + CSPReportOnlyTemplate string + AngularSupportEnabled bool + EnableFrontendSandboxForPlugins []string + DisableGravatar bool + DataProxyWhiteList map[string]bool + ActionsAllowPostURL string TempDataLifetime time.Duration @@ -1555,10 +1555,10 @@ func readSecuritySettings(iniFile *ini.File, cfg *Cfg) error { cfg.CSPReportOnlyEnabled = security.Key("content_security_policy_report_only").MustBool(false) cfg.CSPReportOnlyTemplate = security.Key("content_security_policy_report_only_template").MustString("") - disableFrontendSandboxForPlugins := security.Key("disable_frontend_sandbox_for_plugins").MustString("") - for _, plug := range strings.Split(disableFrontendSandboxForPlugins, ",") { + enableFrontendSandboxForPlugins := security.Key("enable_frontend_sandbox_for_plugins").MustString("") + for _, plug := range strings.Split(enableFrontendSandboxForPlugins, ",") { plug = strings.TrimSpace(plug) - cfg.DisableFrontendSandboxForPlugins = append(cfg.DisableFrontendSandboxForPlugins, plug) + cfg.EnableFrontendSandboxForPlugins = append(cfg.EnableFrontendSandboxForPlugins, plug) } if cfg.CSPEnabled && cfg.CSPTemplate == "" { diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts index 6d7ace4e8ca..cf65adc4811 100644 --- a/public/app/features/plugins/plugin_loader.ts +++ b/public/app/features/plugins/plugin_loader.ts @@ -22,7 +22,7 @@ import { decorateSystemJSFetch, decorateSystemJSResolve, decorateSystemJsOnload import { SystemJSWithLoaderHooks } from './loader/types'; import { buildImportMap, resolveModulePath } from './loader/utils'; import { importPluginModuleInSandbox } from './sandbox/sandbox_plugin_loader'; -import { isFrontendSandboxSupported } from './sandbox/utils'; +import { shouldLoadPluginInFrontendSandbox } from './sandbox/sandbox_plugin_loader_registry'; import { pluginsLogger } from './utils'; const imports = buildImportMap(sharedDependenciesMap); @@ -115,7 +115,7 @@ export async function importPluginModule({ } // the sandboxing environment code cannot work in nodejs and requires a real browser - if (await isFrontendSandboxSupported({ isAngular, pluginId })) { + if (await shouldLoadPluginInFrontendSandbox({ isAngular, pluginId })) { return importPluginModuleInSandbox({ pluginId }); } diff --git a/public/app/features/plugins/sandbox/sandbox_plugin_loader_registry.test.ts b/public/app/features/plugins/sandbox/sandbox_plugin_loader_registry.test.ts new file mode 100644 index 00000000000..9ff282baef8 --- /dev/null +++ b/public/app/features/plugins/sandbox/sandbox_plugin_loader_registry.test.ts @@ -0,0 +1,92 @@ +import { PluginMeta, PluginSignatureType } from '@grafana/data'; +import { config } from '@grafana/runtime'; + +import { getPluginSettings } from '../pluginSettings'; + +import { + shouldLoadPluginInFrontendSandbox, + setSandboxEnabledCheck, + isPluginFrontendSandboxEnabled, +} from './sandbox_plugin_loader_registry'; + +jest.mock('@grafana/runtime', () => ({ + config: { + featureToggles: { pluginsFrontendSandbox: true }, + buildInfo: { env: 'production' }, + enableFrontendSandboxForPlugins: [], + }, +})); + +jest.mock('../pluginSettings', () => ({ + getPluginSettings: jest.fn(), +})); + +const getPluginSettingsMock = getPluginSettings as jest.MockedFunction; + +const fakePlugin: PluginMeta = { + id: 'test-plugin', + name: 'Test Plugin', +} as PluginMeta; + +describe('Sandbox eligibility checks', () => { + const originalNodeEnv = process.env.NODE_ENV; + + beforeEach(() => { + jest.clearAllMocks(); + config.enableFrontendSandboxForPlugins = []; + config.featureToggles.pluginsFrontendSandbox = true; + process.env.NODE_ENV = 'development'; + }); + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + }); + + test('shouldLoadPluginInFrontendSandbox returns false for Angular plugins', async () => { + const result = await shouldLoadPluginInFrontendSandbox({ isAngular: true, pluginId: 'test-plugin' }); + expect(result).toBe(false); + }); + + test('shouldLoadPluginInFrontendSandbox returns false when feature toggle is off', async () => { + config.featureToggles.pluginsFrontendSandbox = false; + const result = await shouldLoadPluginInFrontendSandbox({ pluginId: 'test-plugin' }); + expect(result).toBe(false); + }); + + test('shouldLoadPluginInFrontendSandbox returns false for Grafana-signed plugins', async () => { + getPluginSettingsMock.mockResolvedValue({ ...fakePlugin, signatureType: PluginSignatureType.grafana }); + const result = await shouldLoadPluginInFrontendSandbox({ pluginId: 'test-plugin' }); + expect(result).toBe(false); + }); + + test('shouldLoadPluginInFrontendSandbox returns true for eligible plugins in the list', async () => { + getPluginSettingsMock.mockResolvedValue({ ...fakePlugin, signatureType: PluginSignatureType.community }); + config.enableFrontendSandboxForPlugins = ['test-plugin']; + const result = await shouldLoadPluginInFrontendSandbox({ pluginId: 'test-plugin' }); + expect(result).toBe(true); + }); + + test('isPluginFrontendSandboxEnabled returns false when plugin is not in the enabled list', async () => { + config.enableFrontendSandboxForPlugins = ['other-plugin']; + const result = await isPluginFrontendSandboxEnabled({ pluginId: 'test-plugin' }); + expect(result).toBe(false); + }); + + test('setSandboxEnabledCheck sets custom check function', async () => { + const customCheck = jest.fn().mockResolvedValue(true); + setSandboxEnabledCheck(customCheck); + const result = await shouldLoadPluginInFrontendSandbox({ pluginId: 'test-plugin' }); + expect(customCheck).toHaveBeenCalledWith({ pluginId: 'test-plugin' }); + expect(result).toBe(true); + }); + + test('setSandboxEnabledCheck has precedence over default', async () => { + const customCheck = jest.fn().mockResolvedValue(false); + setSandboxEnabledCheck(customCheck); + // this should be ignored by the custom check + config.enableFrontendSandboxForPlugins = ['test-plugin']; + const result = await shouldLoadPluginInFrontendSandbox({ pluginId: 'test-plugin' }); + expect(customCheck).toHaveBeenCalledWith({ pluginId: 'test-plugin' }); + expect(result).toBe(false); + }); +}); diff --git a/public/app/features/plugins/sandbox/sandbox_plugin_loader_registry.ts b/public/app/features/plugins/sandbox/sandbox_plugin_loader_registry.ts new file mode 100644 index 00000000000..c4bc6128a54 --- /dev/null +++ b/public/app/features/plugins/sandbox/sandbox_plugin_loader_registry.ts @@ -0,0 +1,78 @@ +import { PluginSignatureType } from '@grafana/data'; +import { config } from '@grafana/runtime'; + +import { getPluginSettings } from '../pluginSettings'; + +type SandboxEligibilityCheckParams = { + isAngular?: boolean; + pluginId: string; +}; + +type SandboxEnabledCheck = (params: SandboxEligibilityCheckParams) => Promise; + +/** + * We allow core extensions to register their own + * sandbox enabled checks. + */ +let sandboxEnabledCheck: SandboxEnabledCheck = isPluginFrontendSandboxEnabled; + +export function setSandboxEnabledCheck(checker: SandboxEnabledCheck) { + sandboxEnabledCheck = checker; +} + +export async function shouldLoadPluginInFrontendSandbox({ + isAngular, + pluginId, +}: SandboxEligibilityCheckParams): Promise { + // basic check if the plugin is eligible for the sandbox + if (!(await isPluginFrontendSandboxElegible({ isAngular, pluginId }))) { + return false; + } + + return sandboxEnabledCheck({ isAngular, pluginId }); +} + +/** + * This is a basic check that checks if the plugin is eligible to run in the sandbox. + * It does not check if the plugin is actually enabled for the sandbox. + */ +async function isPluginFrontendSandboxElegible({ + isAngular, + pluginId, +}: SandboxEligibilityCheckParams): Promise { + // Only if the feature is not enabled no support for sandbox + if (!Boolean(config.featureToggles.pluginsFrontendSandbox)) { + return false; + } + + // no support for angular plugins + if (isAngular) { + return false; + } + + // To fast-test and debug the sandbox in the browser (dev mode only). + const sandboxDisableQueryParam = location.search.includes('nosandbox') && config.buildInfo.env === 'development'; + if (sandboxDisableQueryParam) { + return false; + } + + // no sandbox in test mode. it often breaks e2e tests + if (process.env.NODE_ENV === 'test') { + return false; + } + + // don't run grafana-signed plugins in sandbox + const pluginMeta = await getPluginSettings(pluginId); + if (pluginMeta.signatureType === PluginSignatureType.grafana) { + return false; + } + + return true; +} + +/** + * Check if the plugin is enabled for the sandbox via configuration. + */ +export async function isPluginFrontendSandboxEnabled({ pluginId }: SandboxEligibilityCheckParams): Promise { + return Boolean(config.enableFrontendSandboxForPlugins?.includes(pluginId)); +} diff --git a/public/app/features/plugins/sandbox/utils.ts b/public/app/features/plugins/sandbox/utils.ts index 7c5be8bc393..f122040de94 100644 --- a/public/app/features/plugins/sandbox/utils.ts +++ b/public/app/features/plugins/sandbox/utils.ts @@ -2,12 +2,9 @@ import { isNearMembraneProxy } from '@locker/near-membrane-shared'; import { cloneDeep } from 'lodash'; import * as React from 'react'; -import { PluginSignatureType, PluginType } from '@grafana/data'; import { LogContext } from '@grafana/faro-web-sdk'; import { config, createMonitoringLogger } from '@grafana/runtime'; -import { getPluginSettings } from '../pluginSettings'; - import { SandboxedPluginObject } from './types'; const monitorOnly = Boolean(config.featureToggles.frontendSandboxMonitorOnly); @@ -38,49 +35,6 @@ export function logInfo(message: string, context?: LogContext) { sandboxLogger.logInfo(message, context); } -export async function isFrontendSandboxSupported({ - isAngular, - pluginId, -}: { - isAngular?: boolean; - pluginId: string; -}): Promise { - // Only if the feature is not enabled no support for sandbox - if (!Boolean(config.featureToggles.pluginsFrontendSandbox)) { - return false; - } - - // no support for angular plugins - if (isAngular) { - return false; - } - - // To fast test and debug the sandbox in the browser. - const sandboxDisableQueryParam = location.search.includes('nosandbox') && config.buildInfo.env === 'development'; - if (sandboxDisableQueryParam) { - return false; - } - - // if disabled by configuration - const isPluginExcepted = config.disableFrontendSandboxForPlugins.includes(pluginId); - if (isPluginExcepted) { - return false; - } - - // no sandbox in test mode. it often breaks e2e tests - if (process.env.NODE_ENV === 'test') { - return false; - } - - // we don't run grafana-own apps in the sandbox - const pluginMeta = await getPluginSettings(pluginId); - if (pluginMeta.type === PluginType.app && pluginMeta.signatureType === PluginSignatureType.grafana) { - return false; - } - - return true; -} - function isRegex(value: unknown): value is RegExp { return value?.constructor?.name === 'RegExp'; } diff --git a/scripts/grafana-server/custom.ini b/scripts/grafana-server/custom.ini index 95499fabb85..83bf88f1965 100644 --- a/scripts/grafana-server/custom.ini +++ b/scripts/grafana-server/custom.ini @@ -2,6 +2,7 @@ [security] content_security_policy = true content_security_policy_template = """require-trusted-types-for 'script'; script-src 'self' 'unsafe-eval' 'unsafe-inline' 'strict-dynamic' $NONCE;object-src 'none';font-src 'self';style-src 'self' 'unsafe-inline' blob:;img-src * data:;base-uri 'self';connect-src 'self' grafana.com ws://$ROOT_PATH wss://$ROOT_PATH;manifest-src 'self';media-src 'none';form-action 'self';""" +enable_frontend_sandbox_for_plugins = sandbox-app-test,sandbox-test-datasource,sandbox-test-panel [feature_toggles] enable = publicDashboards