Plugins: Add monitoring only mode to frontend sandbox (#70688)

This commit is contained in:
Esteban Beltran 2023-07-05 13:48:25 +02:00 committed by GitHub
parent 549e04a8f1
commit 72f6793344
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 211 additions and 42 deletions

View File

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

View File

@ -98,6 +98,7 @@ export interface FeatureToggles {
extraThemes?: boolean;
lokiPredefinedOperations?: boolean;
pluginsFrontendSandbox?: boolean;
frontendSandboxMonitorOnly?: boolean;
sqlDatasourceDatabaseSelection?: boolean;
cloudWatchLogsMonacoEditor?: boolean;
exploreScrollableLogsContainer?: boolean;

View File

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

View File

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

1 Name Stage Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
79 extraThemes experimental @grafana/grafana-frontend-platform false false false true
80 lokiPredefinedOperations experimental @grafana/observability-logs false false false true
81 pluginsFrontendSandbox experimental @grafana/plugins-platform-backend false false false true
82 frontendSandboxMonitorOnly experimental @grafana/plugins-platform-backend false false false true
83 sqlDatasourceDatabaseSelection preview @grafana/grafana-bi-squad false false false true
84 cloudWatchLogsMonacoEditor experimental @grafana/aws-plugins false false false true
85 exploreScrollableLogsContainer experimental @grafana/observability-logs false false false true

View File

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

View File

@ -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, (originalAttrOrMethod: unknown) => unknown>;
type DistortionMap = Map<unknown, (originalAttrOrMethod: unknown, pluginId: string) => 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) {

View File

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

View File

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

View File

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