diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index f31e3256483..335cb5195e0 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -112,6 +112,7 @@ Experimental features might be changed or removed without prior notice. | `extraThemes` | Enables extra themes | | `lokiPredefinedOperations` | Adds predefined query operations to Loki query editor | | `pluginsFrontendSandbox` | Enables the plugins frontend sandbox | +| `frontendSandboxMonitorOnly` | Enables monitor only in the plugin frontend sandbox (if enabled) | | `cloudWatchLogsMonacoEditor` | Enables the Monaco editor for CloudWatch Logs queries | | `exploreScrollableLogsContainer` | Improves the scrolling behavior of logs in Explore | | `recordedQueriesMulti` | Enables writing multiple items from a single query within Recorded Queries | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index a0a5e83184d..82e5d36eb8c 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -98,6 +98,7 @@ export interface FeatureToggles { extraThemes?: boolean; lokiPredefinedOperations?: boolean; pluginsFrontendSandbox?: boolean; + frontendSandboxMonitorOnly?: boolean; sqlDatasourceDatabaseSelection?: boolean; cloudWatchLogsMonacoEditor?: boolean; exploreScrollableLogsContainer?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 420d3127bcb..995e49fa09c 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -545,6 +545,13 @@ var ( FrontendOnly: true, Owner: grafanaPluginsPlatformSquad, }, + { + Name: "frontendSandboxMonitorOnly", + Description: "Enables monitor only in the plugin frontend sandbox (if enabled)", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaPluginsPlatformSquad, + }, { Name: "sqlDatasourceDatabaseSelection", Description: "Enables previous SQL data source dataset dropdown behavior", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index b24652f8944..9597928bb91 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -79,6 +79,7 @@ dataSourcePageHeader,preview,@grafana/enterprise-datasources,false,false,false,t extraThemes,experimental,@grafana/grafana-frontend-platform,false,false,false,true lokiPredefinedOperations,experimental,@grafana/observability-logs,false,false,false,true pluginsFrontendSandbox,experimental,@grafana/plugins-platform-backend,false,false,false,true +frontendSandboxMonitorOnly,experimental,@grafana/plugins-platform-backend,false,false,false,true sqlDatasourceDatabaseSelection,preview,@grafana/grafana-bi-squad,false,false,false,true cloudWatchLogsMonacoEditor,experimental,@grafana/aws-plugins,false,false,false,true exploreScrollableLogsContainer,experimental,@grafana/observability-logs,false,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 420973044ae..2dd78b03ab0 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -327,6 +327,10 @@ const ( // Enables the plugins frontend sandbox FlagPluginsFrontendSandbox = "pluginsFrontendSandbox" + // FlagFrontendSandboxMonitorOnly + // Enables monitor only in the plugin frontend sandbox (if enabled) + FlagFrontendSandboxMonitorOnly = "frontendSandboxMonitorOnly" + // FlagSqlDatasourceDatabaseSelection // Enables previous SQL data source dataset dropdown behavior FlagSqlDatasourceDatabaseSelection = "sqlDatasourceDatabaseSelection" diff --git a/public/app/features/plugins/sandbox/distortion_map.ts b/public/app/features/plugins/sandbox/distortion_map.ts index e3fd03f0f9d..999e5308da6 100644 --- a/public/app/features/plugins/sandbox/distortion_map.ts +++ b/public/app/features/plugins/sandbox/distortion_map.ts @@ -1,6 +1,9 @@ import { cloneDeep, isFunction } from 'lodash'; +import { config } from '@grafana/runtime'; + import { forbiddenElements } from './constants'; +import { logWarning } from './utils'; /** * Distortions are near-membrane mechanisms to altert JS instrics and DOM APIs. @@ -53,9 +56,11 @@ import { forbiddenElements } from './constants'; * The code in this file defines that generalDistortionMap. */ -type DistortionMap = Map unknown>; +type DistortionMap = Map unknown>; const generalDistortionMap: DistortionMap = new Map(); +const monitorOnly = Boolean(config.featureToggles.frontendSandboxMonitorOnly); + export function getGeneralSandboxDistortionMap() { if (generalDistortionMap.size === 0) { // initialize the distortion map @@ -72,7 +77,15 @@ export function getGeneralSandboxDistortionMap() { return generalDistortionMap; } -function failToSet() { +function failToSet(originalAttrOrMethod: unknown, pluginId: string) { + logWarning(`Plugin ${pluginId} tried to set a sandboxed property`, { + pluginId, + attrOrMethod: String(originalAttrOrMethod), + entity: 'window', + }); + if (monitorOnly) { + return originalAttrOrMethod; + } return () => { throw new Error('Plugins are not allowed to set sandboxed properties'); }; @@ -85,11 +98,22 @@ function distortIframeAttributes(distortions: DistortionMap) { for (const property of iframeHtmlForbiddenProperties) { const descriptor = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, property); if (descriptor) { - function fail() { + function fail(originalAttrOrMethod: unknown, pluginId: string) { + logWarning(`Plugin ${pluginId} tried to access iframe.${property}`, { + pluginId, + attrOrMethod: property, + entity: 'iframe', + }); + + if (monitorOnly) { + return originalAttrOrMethod; + } + return () => { throw new Error('iframe.' + property + ' is not allowed in sandboxed plugins'); }; } + if (descriptor.value) { distortions.set(descriptor.value, fail); } @@ -107,20 +131,23 @@ function distortIframeAttributes(distortions: DistortionMap) { function distortConsole(distortions: DistortionMap) { const descriptor = Object.getOwnPropertyDescriptor(window, 'console'); if (descriptor?.value) { - function sandboxLog(...args: unknown[]) { - console.log(`[plugin]`, ...args); - } - const sandboxConsole = { - log: sandboxLog, - warn: sandboxLog, - error: sandboxLog, - info: sandboxLog, - debug: sandboxLog, - table: sandboxLog, - }; + function getSandboxConsole(originalAttrOrMethod: unknown, pluginId: string) { + // we don't monitor the console because we expect a high volume of calls + if (monitorOnly) { + return originalAttrOrMethod; + } - function getSandboxConsole() { - return sandboxConsole; + function sandboxLog(...args: unknown[]) { + console.log(`[plugin ${pluginId}]`, ...args); + } + return { + log: sandboxLog, + warn: sandboxLog, + error: sandboxLog, + info: sandboxLog, + debug: sandboxLog, + table: sandboxLog, + }; } distortions.set(descriptor.value, getSandboxConsole); @@ -132,9 +159,19 @@ function distortConsole(distortions: DistortionMap) { // set distortions to alert to always output to the console function distortAlert(distortions: DistortionMap) { - function getAlertDistortion() { + function getAlertDistortion(originalAttrOrMethod: unknown, pluginId: string) { + logWarning(`Plugin ${pluginId} accessed window.alert`, { + pluginId, + attrOrMethod: 'alert', + entity: 'window', + }); + + if (monitorOnly) { + return originalAttrOrMethod; + } + return function (...args: unknown[]) { - console.log(`[plugin]`, ...args); + console.log(`[plugin ${pluginId}]`, ...args); }; } const descriptor = Object.getOwnPropertyDescriptor(window, 'alert'); @@ -147,12 +184,22 @@ function distortAlert(distortions: DistortionMap) { } function distortInnerHTML(distortions: DistortionMap) { - function getInnerHTMLDistortion(originalMethod: unknown) { + function getInnerHTMLDistortion(originalMethod: unknown, pluginId: string) { return function innerHTMLDistortion(this: HTMLElement, ...args: string[]) { for (const arg of args) { const lowerCase = arg?.toLowerCase() || ''; for (const forbiddenElement of forbiddenElements) { if (lowerCase.includes('<' + forbiddenElement)) { + logWarning(`Plugin ${pluginId} tried to set ${forbiddenElement} in innerHTML`, { + pluginId, + attrOrMethod: 'innerHTML', + param: forbiddenElement, + entity: 'HTMLElement', + }); + + if (monitorOnly) { + continue; + } throw new Error('<' + forbiddenElement + '> is not allowed in sandboxed plugins'); } } @@ -181,10 +228,18 @@ function distortInnerHTML(distortions: DistortionMap) { } function distortCreateElement(distortions: DistortionMap) { - function getCreateElementDistortion(originalMethod: unknown) { + function getCreateElementDistortion(originalMethod: unknown, pluginId: string) { return function createElementDistortion(this: HTMLElement, arg?: string, options?: unknown) { if (arg && forbiddenElements.includes(arg)) { - return document.createDocumentFragment(); + logWarning(`Plugin ${pluginId} tried to create ${arg}`, { + pluginId, + attrOrMethod: 'createElement', + param: arg, + entity: 'document', + }); + if (!monitorOnly) { + return document.createDocumentFragment(); + } } if (isFunction(originalMethod)) { return originalMethod.apply(this, [arg, options]); @@ -198,10 +253,20 @@ function distortCreateElement(distortions: DistortionMap) { } function distortInsert(distortions: DistortionMap) { - function getInsertDistortion(originalMethod: unknown) { + function getInsertDistortion(originalMethod: unknown, pluginId: string) { return function insertChildDistortion(this: HTMLElement, node?: Node, ref?: Node) { - if (node && forbiddenElements.includes(node.nodeName.toLowerCase())) { - return document.createDocumentFragment(); + const nodeType = node?.nodeName?.toLowerCase() || ''; + + if (node && forbiddenElements.includes(nodeType)) { + logWarning(`Plugin ${pluginId} tried to insert ${nodeType}`, { + pluginId, + attrOrMethod: 'insertChild', + param: nodeType, + entity: 'HTMLElement', + }); + if (!monitorOnly) { + return document.createDocumentFragment(); + } } if (isFunction(originalMethod)) { return originalMethod.call(this, node, ref); @@ -209,10 +274,20 @@ function distortInsert(distortions: DistortionMap) { }; } - function getinsertAdjacentElementDistortion(originalMethod: unknown) { + function getinsertAdjacentElementDistortion(originalMethod: unknown, pluginId: string) { return function insertAdjacentElementDistortion(this: HTMLElement, position?: string, node?: Node) { - if (node && forbiddenElements.includes(node.nodeName.toLowerCase())) { - return document.createDocumentFragment(); + const nodeType = node?.nodeName?.toLowerCase() || ''; + if (node && forbiddenElements.includes(nodeType)) { + logWarning(`Plugin ${pluginId} tried to insert ${nodeType}`, { + pluginId, + attrOrMethod: 'insertAdjacentElement', + param: nodeType, + entity: 'HTMLElement', + }); + + if (!monitorOnly) { + return document.createDocumentFragment(); + } } if (isFunction(originalMethod)) { return originalMethod.call(this, position, node); @@ -240,9 +315,23 @@ function distortInsert(distortions: DistortionMap) { // set distortions to append elements to the document function distortAppend(distortions: DistortionMap) { // append accepts an array of nodes to append https://developer.mozilla.org/en-US/docs/Web/API/Node/append - function getAppendDistortion(originalMethod: unknown) { + function getAppendDistortion(originalMethod: unknown, pluginId: string) { return function appendDistortion(this: HTMLElement, ...args: Node[]) { - const acceptedNodes = args?.filter((node) => !forbiddenElements.includes(node.nodeName.toLowerCase())); + let acceptedNodes = args; + const filteredAcceptedNodes = args?.filter((node) => !forbiddenElements.includes(node.nodeName.toLowerCase())); + if (!monitorOnly) { + acceptedNodes = filteredAcceptedNodes; + } + + if (acceptedNodes.length !== filteredAcceptedNodes.length) { + logWarning(`Plugin ${pluginId} tried to append fobiddenElements`, { + pluginId, + attrOrMethod: 'append', + param: args?.filter((node) => forbiddenElements.includes(node.nodeName.toLowerCase()))?.join(',') || '', + entity: 'HTMLElement', + }); + } + if (isFunction(originalMethod)) { originalMethod.apply(this, acceptedNodes); } @@ -252,10 +341,20 @@ function distortAppend(distortions: DistortionMap) { } // appendChild accepts a single node to add https://developer.mozilla.org/en-US/docs/Web/API/Node/appendChild - function getAppendChildDistortion(originalMethod: unknown) { + function getAppendChildDistortion(originalMethod: unknown, pluginId: string) { return function appendChildDistortion(this: HTMLElement, arg?: Node) { - if (arg && forbiddenElements.includes(arg.nodeName.toLowerCase())) { - return document.createDocumentFragment(); + const nodeType = arg?.nodeName?.toLowerCase() || ''; + if (arg && forbiddenElements.includes(nodeType)) { + logWarning(`Plugin ${pluginId} tried to append ${nodeType}`, { + pluginId, + attrOrMethod: 'appendChild', + param: nodeType, + entity: 'HTMLElement', + }); + + if (!monitorOnly) { + return document.createDocumentFragment(); + } } if (isFunction(originalMethod)) { return originalMethod.call(this, arg); @@ -284,6 +383,7 @@ function distortAppend(distortions: DistortionMap) { } } +// this is not a distortion for security reasons but to make plugins using web workers work correctly. function distortWorkers(distortions: DistortionMap) { const descriptor = Object.getOwnPropertyDescriptor(Worker.prototype, 'postMessage'); function getPostMessageDistortion(originalMethod: unknown) { @@ -306,6 +406,7 @@ function distortWorkers(distortions: DistortionMap) { } } +// this is not a distortion for security reasons but to make plugins using document.defaultView work correctly. function distortDocument(distortions: DistortionMap) { const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'defaultView'); if (descriptor?.get) { diff --git a/public/app/features/plugins/sandbox/document_sandbox.ts b/public/app/features/plugins/sandbox/document_sandbox.ts index 12fd4853e1e..7756f4f29a7 100644 --- a/public/app/features/plugins/sandbox/document_sandbox.ts +++ b/public/app/features/plugins/sandbox/document_sandbox.ts @@ -1,10 +1,13 @@ import { isNearMembraneProxy, ProxyTarget } from '@locker/near-membrane-shared'; +import { config } from '@grafana/runtime'; + import { forbiddenElements } from './constants'; -import { isReactClassComponent } from './utils'; +import { isReactClassComponent, logWarning } from './utils'; // IMPORTANT: NEVER export this symbol from a public (e.g `@grafana/*`) package const SANDBOX_LIVE_VALUE = Symbol.for('@@SANDBOX_LIVE_VALUE'); +const monitorOnly = Boolean(config.featureToggles.frontendSandboxMonitorOnly); export function getSafeSandboxDomElement(element: Element, pluginId: string): Element { const nodeName = Reflect.get(element, 'nodeName'); @@ -26,7 +29,14 @@ export function getSafeSandboxDomElement(element: Element, pluginId: string): El } if (forbiddenElements.includes(nodeName)) { - throw new Error('<' + nodeName + '> is not allowed in sandboxed plugins'); + logWarning('<' + nodeName + '> is not allowed in sandboxed plugins', { + pluginId, + param: nodeName, + }); + + if (!monitorOnly) { + throw new Error('<' + nodeName + '> is not allowed in sandboxed plugins'); + } } // allow elements inside the sandbox or the sandbox body @@ -38,10 +48,15 @@ export function getSafeSandboxDomElement(element: Element, pluginId: string): El return element; } - // any other element gets a mock - const mockElement = document.createElement(nodeName); - mockElement.dataset.grafanaPluginSandboxElement = 'true'; - return mockElement; + if (!monitorOnly) { + // any other element gets a mock + const mockElement = document.createElement(nodeName); + mockElement.dataset.grafanaPluginSandboxElement = 'true'; + // we are not logging this because a high number of warnings can be generated + return mockElement; + } else { + return element; + } } export function isDomElement(obj: unknown): obj is Element { diff --git a/public/app/features/plugins/sandbox/sandbox_plugin_loader.ts b/public/app/features/plugins/sandbox/sandbox_plugin_loader.ts index 9405ccf2d81..fd6ec28da17 100644 --- a/public/app/features/plugins/sandbox/sandbox_plugin_loader.ts +++ b/public/app/features/plugins/sandbox/sandbox_plugin_loader.ts @@ -18,6 +18,7 @@ import { import { sandboxPluginDependencies } from './plugin_dependencies'; import { sandboxPluginComponents } from './sandbox_components'; import { CompartmentDependencyModule, PluginFactoryFunction } from './types'; +import { logError } from './utils'; // Loads near membrane custom formatter for near membrane proxy objects. if (process.env.NODE_ENV !== 'production') { @@ -34,7 +35,12 @@ export async function importPluginModuleInSandbox({ pluginId }: { pluginId: stri } return pluginImportCache.get(pluginId); } catch (e) { - throw new Error(`Could not import plugin ${pluginId} inside sandbox: ` + e); + const error = new Error(`Could not import plugin ${pluginId} inside sandbox: ` + e); + logError(error, { + pluginId, + error: String(e), + }); + throw error; } } @@ -56,7 +62,7 @@ async function doImportPluginModuleInSandbox(meta: PluginMeta): Promise } const distortion = generalDistortionMap.get(originalValue); if (distortion) { - return distortion(originalValue) as ProxyTarget; + return distortion(originalValue, meta.id) as ProxyTarget; } return originalValue; } @@ -100,7 +106,12 @@ async function doImportPluginModuleInSandbox(meta: PluginMeta): Promise const pluginExports = await sandboxPluginComponents(pluginExportsRaw, meta); resolve(pluginExports); } catch (e) { - reject(new Error(`Could not execute plugin ${meta.id}: ` + e)); + const error = new Error(`Could not execute plugin's define ${meta.id}: ` + e); + logError(error, { + pluginId: meta.id, + error: String(e), + }); + reject(error); } }, }), @@ -137,7 +148,12 @@ async function doImportPluginModuleInSandbox(meta: PluginMeta): Promise // of endowments. sandboxEnvironment.evaluate(pluginCode); } catch (e) { - reject(new Error(`Could not execute plugin ${meta.id}: ` + e)); + const error = new Error(`Could not run plugin ${meta.id} inside sandbox: ` + e); + logError(error, { + pluginId: meta.id, + error: String(e), + }); + reject(error); } }); } diff --git a/public/app/features/plugins/sandbox/utils.ts b/public/app/features/plugins/sandbox/utils.ts index 21237b7d4c2..516d7d28739 100644 --- a/public/app/features/plugins/sandbox/utils.ts +++ b/public/app/features/plugins/sandbox/utils.ts @@ -1,7 +1,12 @@ import React from 'react'; +import { LogContext } from '@grafana/faro-web-sdk'; +import { logWarning as logWarningRuntime, logError as logErrorRuntime, config } from '@grafana/runtime'; + import { SandboxedPluginObject } from './types'; +const monitorOnly = Boolean(config.featureToggles.frontendSandboxMonitorOnly); + export function isSandboxedPluginObject(value: unknown): value is SandboxedPluginObject { return !!value && typeof value === 'object' && value?.hasOwnProperty('plugin'); } @@ -13,3 +18,21 @@ export function assertNever(x: never): never { export function isReactClassComponent(obj: unknown): obj is React.Component { return obj instanceof React.Component; } + +export function logWarning(message: string, context?: LogContext) { + context = { + ...context, + source: 'sandbox', + monitorOnly: String(monitorOnly), + }; + logWarningRuntime(message, context); +} + +export function logError(error: Error, context?: LogContext) { + context = { + ...context, + source: 'sandbox', + monitorOnly: String(monitorOnly), + }; + logErrorRuntime(error, context); +}