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
9 changed files with 211 additions and 42 deletions

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