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
This commit is contained in:
Esteban Beltran 2024-10-17 16:56:50 +02:00 committed by GitHub
parent beac7de4df
commit f248a55576
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 203 additions and 424 deletions

View File

@ -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 =

View File

@ -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 =

View File

@ -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]

View File

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

View File

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

View File

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

View File

@ -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');

View File

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

View File

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

View File

@ -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"`

View File

@ -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,

View File

@ -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 == "" {

View File

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

View File

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

View File

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

View File

@ -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<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 {
return value?.constructor?.name === 'RegExp';
}

View File

@ -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