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
pull/94894/head^2
Esteban Beltran 8 months ago committed by GitHub
parent beac7de4df
commit f248a55576
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      conf/defaults.ini
  2. 5
      conf/sample.ini
  3. 5
      docs/sources/setup-grafana/configure-grafana/_index.md
  4. 128
      e2e/old-arch/panels-suite/frontend-sandbox-panel.spec.ts
  5. 71
      e2e/old-arch/various-suite/frontend-sandbox-app.spec.ts
  6. 155
      e2e/old-arch/various-suite/frontend-sandbox-datasource.spec.ts
  7. 11
      e2e/panels-suite/frontend-sandbox-panel.spec.ts
  8. 2
      e2e/various-suite/frontend-sandbox-datasource.spec.ts
  9. 2
      packages/grafana-runtime/src/config.ts
  10. 2
      pkg/api/dtos/frontend_settings.go
  11. 2
      pkg/api/frontendsettings.go
  12. 18
      pkg/setting/setting.go
  13. 4
      public/app/features/plugins/plugin_loader.ts
  14. 92
      public/app/features/plugins/sandbox/sandbox_plugin_loader_registry.test.ts
  15. 78
      public/app/features/plugins/sandbox/sandbox_plugin_loader_registry.ts
  16. 46
      public/app/features/plugins/sandbox/utils.ts
  17. 1
      scripts/grafana-server/custom.ini

@ -403,8 +403,9 @@ angular_support_enabled = false
# The CSRF check will be executed even if the request has no login cookie. # The CSRF check will be executed even if the request has no login cookie.
csrf_always_check = false csrf_always_check = false
# Comma-separated list of plugins ids that won't be loaded inside the frontend sandbox # Comma-separated list of plugins ids that will be loaded inside the frontend sandbox
disable_frontend_sandbox_for_plugins = grafana-incident-app # 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 # 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 = actions_allow_post_url =

@ -408,8 +408,9 @@
# The CSRF check will be executed even if the request has no login cookie. # The CSRF check will be executed even if the request has no login cookie.
;csrf_always_check = false ;csrf_always_check = false
# Comma-separated list of plugins ids that won't be loaded inside the frontend sandbox # Comma-separated list of plugins ids that will be loaded inside the frontend sandbox
;disable_frontend_sandbox_for_plugins = # 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 # 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 = ;actions_allow_post_url =

@ -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`). 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 Comma-separated list of plugins ids that will be loaded inside the frontend sandbox.
option for plugins that are known to have problems running inside the frontend sandbox.
## [snapshots] ## [snapshots]

@ -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();
});
});

@ -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();
});
});

@ -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();
});
});

@ -3,7 +3,7 @@ import { e2e } from '../utils';
const DASHBOARD_ID = 'c46b2460-16b7-42a5-82d1-b07fbf431950'; 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 // 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(() => { beforeEach(() => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true); e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true);
return e2e.flows.importDashboard(panelSandboxDashboard, 1000, 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"]').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'); 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'); 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.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.wait(100); // small delay to prevent false positives from too fast tests
cy.get('[data-sandbox-test="panel-editor"]').should('not.exist'); cy.get('[data-sandbox-test="panel-editor"]').should('not.exist');

@ -7,7 +7,7 @@ let DATASOURCE_CONNECTION_ID = '';
const DATASOURCE_TYPED_NAME = 'SandboxDatasourceInstance'; const DATASOURCE_TYPED_NAME = 'SandboxDatasourceInstance';
// Skipping due to flakiness/race conditions with same old arch test e2e/various-suite/frontend-sandbox-datasource.spec.ts // 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(() => { before(() => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true); e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true);

@ -189,7 +189,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
}; };
tokenExpirationDayLimit: undefined; tokenExpirationDayLimit: undefined;
disableFrontendSandboxForPlugins: string[] = []; enableFrontendSandboxForPlugins: string[] = [];
sharedWithMeFolderUID: string | undefined; sharedWithMeFolderUID: string | undefined;
rootFolderUID: string | undefined; rootFolderUID: string | undefined;
localFileSystemAvailable: boolean | undefined; localFileSystemAvailable: boolean | undefined;

@ -203,7 +203,7 @@ type FrontendSettingsDTO struct {
DisableSanitizeHtml bool `json:"disableSanitizeHtml"` DisableSanitizeHtml bool `json:"disableSanitizeHtml"`
TrustedTypesDefaultPolicyEnabled bool `json:"trustedTypesDefaultPolicyEnabled"` TrustedTypesDefaultPolicyEnabled bool `json:"trustedTypesDefaultPolicyEnabled"`
CSPReportOnlyEnabled bool `json:"cspReportOnlyEnabled"` CSPReportOnlyEnabled bool `json:"cspReportOnlyEnabled"`
DisableFrontendSandboxForPlugins []string `json:"disableFrontendSandboxForPlugins"` EnableFrontendSandboxForPlugins []string `json:"enableFrontendSandboxForPlugins"`
ExploreDefaultTimeOffset string `json:"exploreDefaultTimeOffset"` ExploreDefaultTimeOffset string `json:"exploreDefaultTimeOffset"`
Auth FrontendSettingsAuthDTO `json:"auth"` Auth FrontendSettingsAuthDTO `json:"auth"`

@ -231,7 +231,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
CSPReportOnlyEnabled: hs.Cfg.CSPReportOnlyEnabled, CSPReportOnlyEnabled: hs.Cfg.CSPReportOnlyEnabled,
DateFormats: hs.Cfg.DateFormats, DateFormats: hs.Cfg.DateFormats,
SecureSocksDSProxyEnabled: hs.Cfg.SecureSocksDSProxy.Enabled && hs.Cfg.SecureSocksDSProxy.ShowUI, SecureSocksDSProxyEnabled: hs.Cfg.SecureSocksDSProxy.Enabled && hs.Cfg.SecureSocksDSProxy.ShowUI,
DisableFrontendSandboxForPlugins: hs.Cfg.DisableFrontendSandboxForPlugins, EnableFrontendSandboxForPlugins: hs.Cfg.EnableFrontendSandboxForPlugins,
PublicDashboardAccessToken: c.PublicDashboardAccessToken, PublicDashboardAccessToken: c.PublicDashboardAccessToken,
PublicDashboardsEnabled: hs.Cfg.PublicDashboardsEnabled, PublicDashboardsEnabled: hs.Cfg.PublicDashboardsEnabled,
CloudMigrationIsTarget: isCloudMigrationTarget, CloudMigrationIsTarget: isCloudMigrationTarget,

@ -175,12 +175,12 @@ type Cfg struct {
// CSPReportEnabled toggles Content Security Policy Report Only support. // CSPReportEnabled toggles Content Security Policy Report Only support.
CSPReportOnlyEnabled bool CSPReportOnlyEnabled bool
// CSPReportOnlyTemplate contains the Content Security Policy Report Only template. // CSPReportOnlyTemplate contains the Content Security Policy Report Only template.
CSPReportOnlyTemplate string CSPReportOnlyTemplate string
AngularSupportEnabled bool AngularSupportEnabled bool
DisableFrontendSandboxForPlugins []string EnableFrontendSandboxForPlugins []string
DisableGravatar bool DisableGravatar bool
DataProxyWhiteList map[string]bool DataProxyWhiteList map[string]bool
ActionsAllowPostURL string ActionsAllowPostURL string
TempDataLifetime time.Duration 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.CSPReportOnlyEnabled = security.Key("content_security_policy_report_only").MustBool(false)
cfg.CSPReportOnlyTemplate = security.Key("content_security_policy_report_only_template").MustString("") cfg.CSPReportOnlyTemplate = security.Key("content_security_policy_report_only_template").MustString("")
disableFrontendSandboxForPlugins := security.Key("disable_frontend_sandbox_for_plugins").MustString("") enableFrontendSandboxForPlugins := security.Key("enable_frontend_sandbox_for_plugins").MustString("")
for _, plug := range strings.Split(disableFrontendSandboxForPlugins, ",") { for _, plug := range strings.Split(enableFrontendSandboxForPlugins, ",") {
plug = strings.TrimSpace(plug) plug = strings.TrimSpace(plug)
cfg.DisableFrontendSandboxForPlugins = append(cfg.DisableFrontendSandboxForPlugins, plug) cfg.EnableFrontendSandboxForPlugins = append(cfg.EnableFrontendSandboxForPlugins, plug)
} }
if cfg.CSPEnabled && cfg.CSPTemplate == "" { if cfg.CSPEnabled && cfg.CSPTemplate == "" {

@ -22,7 +22,7 @@ import { decorateSystemJSFetch, decorateSystemJSResolve, decorateSystemJsOnload
import { SystemJSWithLoaderHooks } from './loader/types'; import { SystemJSWithLoaderHooks } from './loader/types';
import { buildImportMap, resolveModulePath } from './loader/utils'; import { buildImportMap, resolveModulePath } from './loader/utils';
import { importPluginModuleInSandbox } from './sandbox/sandbox_plugin_loader'; import { importPluginModuleInSandbox } from './sandbox/sandbox_plugin_loader';
import { isFrontendSandboxSupported } from './sandbox/utils'; import { shouldLoadPluginInFrontendSandbox } from './sandbox/sandbox_plugin_loader_registry';
import { pluginsLogger } from './utils'; import { pluginsLogger } from './utils';
const imports = buildImportMap(sharedDependenciesMap); 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 // 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 }); return importPluginModuleInSandbox({ pluginId });
} }

@ -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<typeof getPluginSettings>;
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);
});
});

@ -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<boolean>;
/**
* 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<boolean> {
// 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<boolean> {
// 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<boolean> {
return Boolean(config.enableFrontendSandboxForPlugins?.includes(pluginId));
}

@ -2,12 +2,9 @@ import { isNearMembraneProxy } from '@locker/near-membrane-shared';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import * as React from 'react'; import * as React from 'react';
import { PluginSignatureType, PluginType } from '@grafana/data';
import { LogContext } from '@grafana/faro-web-sdk'; import { LogContext } from '@grafana/faro-web-sdk';
import { config, createMonitoringLogger } from '@grafana/runtime'; import { config, createMonitoringLogger } from '@grafana/runtime';
import { getPluginSettings } from '../pluginSettings';
import { SandboxedPluginObject } from './types'; import { SandboxedPluginObject } from './types';
const monitorOnly = Boolean(config.featureToggles.frontendSandboxMonitorOnly); const monitorOnly = Boolean(config.featureToggles.frontendSandboxMonitorOnly);
@ -38,49 +35,6 @@ export function logInfo(message: string, context?: LogContext) {
sandboxLogger.logInfo(message, context); sandboxLogger.logInfo(message, context);
} }
export async function isFrontendSandboxSupported({
isAngular,
pluginId,
}: {
isAngular?: boolean;
pluginId: string;
}): Promise<boolean> {
// 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 { function isRegex(value: unknown): value is RegExp {
return value?.constructor?.name === 'RegExp'; return value?.constructor?.name === 'RegExp';
} }

@ -2,6 +2,7 @@
[security] [security]
content_security_policy = true 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';""" 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] [feature_toggles]
enable = publicDashboards enable = publicDashboards

Loading…
Cancel
Save