diff --git a/.betterer.results b/.betterer.results index cbc0ba39a4e..85dcb08e9ac 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2900,9 +2900,13 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "7"], [0, 0, 0, "Do not use any type assertions.", "8"] ], + "public/app/features/plugins/sandbox/sandbox_components.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], "public/app/features/plugins/sandbox/sandbox_plugin_loader.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"] + [0, 0, 0, "Do not use any type assertions.", "1"], + [0, 0, 0, "Do not use any type assertions.", "2"] ], "public/app/features/plugins/sql/components/visual-query-builder/AwesomeQueryBuilder.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], diff --git a/package.json b/package.json index 993a38d2cdb..e2666ba5e99 100644 --- a/package.json +++ b/package.json @@ -271,8 +271,9 @@ "@lezer/common": "1.0.2", "@lezer/highlight": "1.1.3", "@lezer/lr": "1.3.3", - "@locker/near-membrane-dom": "^0.12.14", - "@locker/near-membrane-shared": "^0.12.14", + "@locker/near-membrane-dom": "^0.12.15", + "@locker/near-membrane-shared": "^0.12.15", + "@locker/near-membrane-shared-dom": "^0.12.15", "@opentelemetry/api": "1.4.0", "@opentelemetry/exporter-collector": "0.25.0", "@opentelemetry/semantic-conventions": "1.14.0", diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts index c0e7885c7d9..1857c4dd6ea 100644 --- a/public/app/features/plugins/plugin_loader.ts +++ b/public/app/features/plugins/plugin_loader.ts @@ -227,7 +227,14 @@ export async function importPluginModule({ } function isFrontendSandboxSupported(isAngular?: boolean): boolean { - return !isAngular && Boolean(config.featureToggles.pluginsFrontendSandbox) && process.env.NODE_ENV !== 'test'; + // To fast test and debug the sandbox in the browser. + const sandboxQueryParam = location.search.includes('nosandbox') && config.buildInfo.env === 'development'; + return ( + !isAngular && + Boolean(config.featureToggles.pluginsFrontendSandbox) && + process.env.NODE_ENV !== 'test' && + !sandboxQueryParam + ); } export function importDataSourcePlugin(meta: grafanaData.DataSourcePluginMeta): Promise { diff --git a/public/app/features/plugins/sandbox/README.md b/public/app/features/plugins/sandbox/README.md new file mode 100644 index 00000000000..d2fb03e0684 --- /dev/null +++ b/public/app/features/plugins/sandbox/README.md @@ -0,0 +1,96 @@ +# Frontend plugin sandboxing. + +This folder contains the code responsible for front end plugins sandboxing. A small part of the code +exists in the plugin loader as a required entry point here. + +# General idea + +The general idea of the sandbox is javascript [shadow realms](https://github.com/tc39/proposal-shadowrealm). + +With shadowrealms you can run javascript code that runs on the same thread as your main application but doesn't have +access to the same global scope as your main application. + +Sadly at the moment of writing this readme file, shadow realms are still in a proposal stage and not yet developed for +any browser. Instead we are using a library that implements a similar concept called [near membrane](https://github.com/salesforce/near-membrane) + +# Plugin Loading + +When a plugin is marked for loading, grafana decides if it should load it in the incubator realm (no sandbox, same as loading any script on the browser) or in a sandbox (child realm, evaluated with near-membrane). + +- If a plugin is marked to load in the incubator realm, it is loaded via systemJS + +- If a plugin is marked to load in a sandbox, first the source code is downloaded with `fetch`, then pre-processed to adjust sourceMaps and CDNs and finally evaluated inside a new near-membrane virtual environment. + +In either case, Grafana receives a pluginExport object that later uses to initialize plugins. For Grafana's core, this +pluginExport is idential in functionality and properties regardless of the loading method. + +# Plugin execution + +The plugin execution from Grafana's perspective doesn't change in anyway when loaded inside a sandbox. + +The main difference is that all the plugin code executed that was evaluated inside a child realm, will always execute in +the child realm, regardless of where it is called. + +# Components rendering and React + +Likewise the sandboxed components are rendered using React as usual. The main difference is that when React executes the +React components (functions) that come from a child realm, those components will be executed in the child realm context +with its limited scope and distortions (to be covered later). This in general terms doesn't affect the way React works, +access to React contexts, portals, etc... + +## Event handlers + +Any event handling (clicks, keyboard, etc..) by components is done in the child realm, since those event handlers where +defined inside the child realm. + +## DOM API and DOM Element access + +Plugins can have access to DOM Elements in the present document using the regular APIs but there are restriction set in +place by distortions (see later) + +## Error handling + +Errors inside the sandbox are reported as errors inside the sandbox. This means the stacktrace of an error from a plugin +inside the sandbox will contain additional information of the many layers that exist between the regular grafana code +and the plugin code. + +## Performance + +Due to the nature of distortions (see later). There could be a minimal performance degradation in specific scenarios, mostly +those plugins that use web workers. Performance is still under tests when this was written. + +# Distortions + +Distortions is the mechanism to intercept calls from the child realm code to JS APIS and DOM APIs. e.g: `Array.map` or +`document.getElement`. + +Distortions allow to replace the function that will execute inside the child realm wnen the function is invoked. + +Distortions also allow to intercept the exchange of objects between the child realm and the incubator realm, we can, for +example, inspect all DOM elements access and generally speaking all objects that go to the child realm. + +Currently the distortions implemented are in the distortion_map folder and mostly revolve around preventing plugins from +creating forbidden elements (iframes) and to fix functionality that is otherwise broken inside the child realm (e.g. Web +workers) + +## Diagram + +Here's an example of a distortion in an fetch call from inside a child realm upon an onClick event: + +```mermaid +sequenceDiagram + participant blue as Incubator Realm + participant proxy as DistortionHandler + participant red as Child Window + blue ->> red: Handle (onClick) + red ->> red: run handleClick + Note right of red: handleClick tries to
run a fetch() request + red ->> proxy: get fetch function + Note over red, proxy: This is not the fetch call itself, this is
"give me the function object I'll use
when I call fetch" + proxy ->> blue: should distord [fetch] ? + blue ->> proxy: use [distortedFetch] + proxy ->> red: use [distortedFetch] (modified object) + Note over red, proxy: Returns a function that will
be called as the "fetch" function + red ->> red: run fetch + Note right of red: Code runs a fetch() request
using the distorted fetch function +``` diff --git a/public/app/features/plugins/sandbox/constants.ts b/public/app/features/plugins/sandbox/constants.ts new file mode 100644 index 00000000000..3d07fbce3b9 --- /dev/null +++ b/public/app/features/plugins/sandbox/constants.ts @@ -0,0 +1 @@ +export const forbiddenElements = ['script', 'iframe']; diff --git a/public/app/features/plugins/sandbox/distortion_map.ts b/public/app/features/plugins/sandbox/distortion_map.ts index b62495ff655..e3fd03f0f9d 100644 --- a/public/app/features/plugins/sandbox/distortion_map.ts +++ b/public/app/features/plugins/sandbox/distortion_map.ts @@ -1,8 +1,81 @@ -type DistortionMap = Map; +import { cloneDeep, isFunction } from 'lodash'; + +import { forbiddenElements } from './constants'; + +/** + * Distortions are near-membrane mechanisms to altert JS instrics and DOM APIs. + * + * Everytime a plugin tries to use a js instricis (e.g. Array.concat) or a DOM API (e.g. document.createElement) + * or access any of its attributes a distortion callback is used. + * + * The distortion callback has a single parameter which is usually the "native" function responsible + * for the API, but generally speaking is the value that the plugin would normally get. Note that here by + * "value" we mean the function the plugin would execute, not the value from executing the function. + * + * To compare the native code passed to the distortion callback and know if should we distorted or not we need + * to get the object descriptors of these native functions using Object.getOwnPropertyDescriptors. + * + * For example: + * + * If the distortionCallback is asking for a distortion for the `Array.concat` function + * one will see `ƒ concat() { [native code] }` as the parameter to the distortion callback. + * + * Inside the callback we could compare this with the descriptor value: + * + * ``` + * function distortionCallback(valueToDistort: unknown){ + * const descriptor = Object.getOwnPropertyDescriptors(Array.prototype, 'concat') + * if (descriptor.value === valueToDistort) { + * // distorted replacement function + * return ArrayConcatReplacementFunction; + * } + * // original + * return valueToDistort; + * } + * ``` + * + * To avoid the verbosity of the previous code as more and more distortions are applied it is easier to use + * a Map. Map keys can be objects (including native functions). + * + * This allows to simplify the previous code: + * + * ``` + * function distortionCallback(valueToDistort: unknown){ + * if (generalDistortionMap.has(valueToDistort)) { + * // Map does the comparison easier + * return generalDistortionMap.get(valueToDistort); + * } + * // original + * return valueToDistort; + * } + * ``` + * + * The code in this file defines that generalDistortionMap. + */ + +type DistortionMap = Map unknown>; const generalDistortionMap: DistortionMap = new Map(); +export function getGeneralSandboxDistortionMap() { + if (generalDistortionMap.size === 0) { + // initialize the distortion map + distortIframeAttributes(generalDistortionMap); + distortConsole(generalDistortionMap); + distortAlert(generalDistortionMap); + distortAppend(generalDistortionMap); + distortInsert(generalDistortionMap); + distortInnerHTML(generalDistortionMap); + distortCreateElement(generalDistortionMap); + distortWorkers(generalDistortionMap); + distortDocument(generalDistortionMap); + } + return generalDistortionMap; +} + function failToSet() { - throw new Error('Plugins are not allowed to set sandboxed properties'); + return () => { + throw new Error('Plugins are not allowed to set sandboxed properties'); + }; } // sets distortion to protect iframe elements @@ -13,7 +86,9 @@ function distortIframeAttributes(distortions: DistortionMap) { const descriptor = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, property); if (descriptor) { function fail() { - throw new Error('iframe.' + property + ' is not allowed in sandboxed plugins'); + return () => { + throw new Error('iframe.' + property + ' is not allowed in sandboxed plugins'); + }; } if (descriptor.value) { distortions.set(descriptor.value, fail); @@ -28,8 +103,8 @@ function distortIframeAttributes(distortions: DistortionMap) { } } +// set distortions to always prefix any usage of console function distortConsole(distortions: DistortionMap) { - // distorts window.console to prefix it const descriptor = Object.getOwnPropertyDescriptor(window, 'console'); if (descriptor?.value) { function sandboxLog(...args: unknown[]) { @@ -41,33 +116,214 @@ function distortConsole(distortions: DistortionMap) { error: sandboxLog, info: sandboxLog, debug: sandboxLog, + table: sandboxLog, }; - distortions.set(descriptor.value, sandboxConsole); + function getSandboxConsole() { + return sandboxConsole; + } + + distortions.set(descriptor.value, getSandboxConsole); } if (descriptor?.set) { distortions.set(descriptor.set, failToSet); } } +// set distortions to alert to always output to the console function distortAlert(distortions: DistortionMap) { + function getAlertDistortion() { + return function (...args: unknown[]) { + console.log(`[plugin]`, ...args); + }; + } const descriptor = Object.getOwnPropertyDescriptor(window, 'alert'); if (descriptor?.value) { - function sandboxAlert(...args: unknown[]) { - console.log(`[plugin]`, ...args); - } - distortions.set(descriptor.value, sandboxAlert); + distortions.set(descriptor.value, getAlertDistortion); } if (descriptor?.set) { distortions.set(descriptor.set, failToSet); } } -export function getGeneralSandboxDistortionMap() { - if (generalDistortionMap.size === 0) { - distortIframeAttributes(generalDistortionMap); - distortConsole(generalDistortionMap); - distortAlert(generalDistortionMap); +function distortInnerHTML(distortions: DistortionMap) { + function getInnerHTMLDistortion(originalMethod: unknown) { + 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)) { + throw new Error('<' + forbiddenElement + '> is not allowed in sandboxed plugins'); + } + } + } + + if (isFunction(originalMethod)) { + originalMethod.apply(this, args); + } + }; + } + const descriptors = [ + Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML'), + Object.getOwnPropertyDescriptor(Element.prototype, 'outerHTML'), + Object.getOwnPropertyDescriptor(Element.prototype, 'insertAdjacentHTML'), + Object.getOwnPropertyDescriptor(DOMParser.prototype, 'parseFromString'), + ]; + + for (const descriptor of descriptors) { + if (descriptor?.set) { + distortions.set(descriptor.set, getInnerHTMLDistortion); + } + if (descriptor?.value) { + distortions.set(descriptor.value, getInnerHTMLDistortion); + } + } +} + +function distortCreateElement(distortions: DistortionMap) { + function getCreateElementDistortion(originalMethod: unknown) { + return function createElementDistortion(this: HTMLElement, arg?: string, options?: unknown) { + if (arg && forbiddenElements.includes(arg)) { + return document.createDocumentFragment(); + } + if (isFunction(originalMethod)) { + return originalMethod.apply(this, [arg, options]); + } + }; + } + const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'createElement'); + if (descriptor?.value) { + distortions.set(descriptor.value, getCreateElementDistortion); + } +} + +function distortInsert(distortions: DistortionMap) { + function getInsertDistortion(originalMethod: unknown) { + return function insertChildDistortion(this: HTMLElement, node?: Node, ref?: Node) { + if (node && forbiddenElements.includes(node.nodeName.toLowerCase())) { + return document.createDocumentFragment(); + } + if (isFunction(originalMethod)) { + return originalMethod.call(this, node, ref); + } + }; + } + + function getinsertAdjacentElementDistortion(originalMethod: unknown) { + return function insertAdjacentElementDistortion(this: HTMLElement, position?: string, node?: Node) { + if (node && forbiddenElements.includes(node.nodeName.toLowerCase())) { + return document.createDocumentFragment(); + } + if (isFunction(originalMethod)) { + return originalMethod.call(this, position, node); + } + }; + } + + const descriptors = [ + Object.getOwnPropertyDescriptor(Node.prototype, 'insertBefore'), + Object.getOwnPropertyDescriptor(Node.prototype, 'replaceChild'), + ]; + + for (const descriptor of descriptors) { + if (descriptor?.value) { + distortions.set(descriptor.set, getInsertDistortion); + } + } + + const descriptorAdjacent = Object.getOwnPropertyDescriptor(Element.prototype, 'insertAdjacentElement'); + if (descriptorAdjacent?.value) { + distortions.set(descriptorAdjacent.set, getinsertAdjacentElementDistortion); + } +} + +// 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) { + return function appendDistortion(this: HTMLElement, ...args: Node[]) { + const acceptedNodes = args?.filter((node) => !forbiddenElements.includes(node.nodeName.toLowerCase())); + if (isFunction(originalMethod)) { + originalMethod.apply(this, acceptedNodes); + } + // https://developer.mozilla.org/en-US/docs/Web/API/Element/append#return_value + return undefined; + }; + } + + // appendChild accepts a single node to add https://developer.mozilla.org/en-US/docs/Web/API/Node/appendChild + function getAppendChildDistortion(originalMethod: unknown) { + return function appendChildDistortion(this: HTMLElement, arg?: Node) { + if (arg && forbiddenElements.includes(arg.nodeName.toLowerCase())) { + return document.createDocumentFragment(); + } + if (isFunction(originalMethod)) { + return originalMethod.call(this, arg); + } + }; + } + + const descriptors = [ + Object.getOwnPropertyDescriptor(Element.prototype, 'append'), + Object.getOwnPropertyDescriptor(Element.prototype, 'prepend'), + Object.getOwnPropertyDescriptor(Element.prototype, 'after'), + Object.getOwnPropertyDescriptor(Element.prototype, 'before'), + Object.getOwnPropertyDescriptor(Document.prototype, 'append'), + Object.getOwnPropertyDescriptor(Document.prototype, 'prepend'), + ]; + + for (const descriptor of descriptors) { + if (descriptor?.value) { + distortions.set(descriptor.value, getAppendDistortion); + } + } + + const appendChildDescriptor = Object.getOwnPropertyDescriptor(Node.prototype, 'appendChild'); + if (appendChildDescriptor?.value) { + distortions.set(appendChildDescriptor.value, getAppendChildDistortion); + } +} + +function distortWorkers(distortions: DistortionMap) { + const descriptor = Object.getOwnPropertyDescriptor(Worker.prototype, 'postMessage'); + function getPostMessageDistortion(originalMethod: unknown) { + return function postMessageDistortion(this: Worker, ...args: unknown[]) { + // proxies can't be serialized by postMessage algorithm + // the only way to pass it through is to send a cloned version + // objects passed to postMessage should be clonable + try { + const newArgs: unknown[] = cloneDeep(args); + if (isFunction(originalMethod)) { + originalMethod.apply(this, newArgs); + } + } catch (e) { + throw new Error('postMessage arguments are invalid objects'); + } + }; + } + if (descriptor?.value) { + distortions.set(descriptor.value, getPostMessageDistortion); + } +} + +function distortDocument(distortions: DistortionMap) { + const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'defaultView'); + if (descriptor?.get) { + distortions.set(descriptor.get, () => { + return () => { + return window; + }; + }); + } + + const documentForbiddenMethods = ['write']; + for (const method of documentForbiddenMethods) { + const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, method); + if (descriptor?.set) { + distortions.set(descriptor.set, failToSet); + } + if (descriptor?.value) { + distortions.set(descriptor.value, failToSet); + } } - return generalDistortionMap; } diff --git a/public/app/features/plugins/sandbox/document_sandbox.ts b/public/app/features/plugins/sandbox/document_sandbox.ts new file mode 100644 index 00000000000..e0750a9a785 --- /dev/null +++ b/public/app/features/plugins/sandbox/document_sandbox.ts @@ -0,0 +1,108 @@ +import { ProxyTarget } from '@locker/near-membrane-shared'; + +import { forbiddenElements } from './constants'; + +// IMPORTANT: NEVER export this symbol from a public (e.g `@grafana/*`) package +const SANDBOX_LIVE_VALUE = Symbol.for('@@SANDBOX_LIVE_VALUE'); + +export function getSafeSandboxDomElement(element: Element, pluginId: string): Element { + const nodeName = Reflect.get(element, 'nodeName'); + + // the condition redundancy is intentional + if (nodeName === 'body' || element === document.body) { + return document.body; + } + + // allow access to the head + // the condition redundancy is intentional + if (nodeName === 'head' || element === document.head) { + return element; + } + + // allow access to the HTML element + if (element === document.documentElement) { + return element; + } + + if (forbiddenElements.includes(nodeName)) { + throw new Error('<' + nodeName + '> is not allowed in sandboxed plugins'); + } + + // allow elements inside the sandbox or the sandbox body + if (isDomElementInsideSandbox(element, pluginId)) { + return element; + } + + if (element.parentNode === document.body || element.closest('#reactRoot') === null) { + return element; + } + + // any other element gets a mock + const mockElement = document.createElement(nodeName); + mockElement.dataset.grafanaPluginSandboxElement = 'true'; + return mockElement; +} + +export function isDomElement(obj: unknown): obj is Element { + if (typeof obj === 'object' && obj instanceof Element) { + try { + return obj.nodeName !== undefined; + } catch (e) { + return false; + } + } + return false; +} + +/** + * Mark an element style attribute as a live target inside the sandbox + * A "live target" is an object which attributes can be observed + * and modified directly inside the sandbox + * + * This is necessary for plugins working with style attributes to work in Chrome + */ +export function markDomElementStyleAsALiveTarget(el: Element) { + if ( + // only HTMLElement's (extends Element) have a style attribute + el instanceof HTMLElement && + // do not define it twice + !Object.hasOwn(el.style, SANDBOX_LIVE_VALUE) + ) { + Reflect.defineProperty(el.style, SANDBOX_LIVE_VALUE, {}); + } +} + +export function isLiveTarget(el: ProxyTarget) { + return Object.hasOwn(el, SANDBOX_LIVE_VALUE); +} + +/* + * An element is considered to be inside the sandbox if: + * - is not part of the document (detached) + * - is inside a div[data-plugin-sandbox] + * + */ +export function isDomElementInsideSandbox(el: Element, pluginId: string): boolean { + return !document.contains(el) || el.closest(`[data-plugin-sandbox=${pluginId}]`) !== null; +} + +let sandboxBody: HTMLDivElement; + +export function getSandboxMockBody(): Element { + if (!sandboxBody) { + sandboxBody = document.createElement('div'); + sandboxBody.setAttribute('id', 'grafana-plugin-sandbox-body'); + + // the following dataset redundancy is intentional + sandboxBody.setAttribute('data-plugin-sandbox', 'true'); + sandboxBody.dataset.pluginSandbox = 'sandboxed-plugin'; + + sandboxBody.style.width = '100%'; + sandboxBody.style.height = '0%'; + sandboxBody.style.overflow = 'hidden'; + sandboxBody.style.top = '0'; + sandboxBody.style.left = '0'; + document.body.appendChild(sandboxBody); + } + return sandboxBody; +} diff --git a/public/app/features/plugins/sandbox/sandbox_components.tsx b/public/app/features/plugins/sandbox/sandbox_components.tsx new file mode 100644 index 00000000000..cb958136bbb --- /dev/null +++ b/public/app/features/plugins/sandbox/sandbox_components.tsx @@ -0,0 +1,102 @@ +import { isFunction } from 'lodash'; +import React, { ComponentType, FC } from 'react'; + +import { PluginConfigPage, PluginExtensionConfig, PluginMeta } from '@grafana/data'; + +import { SandboxedPluginObject } from './types'; +import { isSandboxedPluginObject } from './utils'; + +/** + * Plugins must render their components inside a div with a `data-plugin-sandbox` attribute + * that has their pluginId as value. + * If they don't they won't work as expected because they won't be able to get DOM elements + * This affect all type of plugins. + * + * One could say this wrapping should occur inside the Panel,Datasource and App clases inside `@grafana/*` + * packages like `@grafana/data` but this is not the case. Even though this code is less future-proof than + * putting it there we have the following cases to cover: + * + * - plugins could start bundling grafana dependencies: thus not getting updates on sandboxing code or worse, + * modifying the code to escape the sandbox + * - we leak sandboxing code outside of the sandbox configuration. This mean some sandboxing leftover could be + * left in non-sandboxed code (e.g. sandbox wrappers showing up even if sandbox is disabled) + * + * The biggest con is that we must maintain this code to keep it up to date with possible additional components and + * classes that plugins could bring. + * + */ +export async function sandboxPluginComponents( + pluginExports: unknown, + meta: PluginMeta +): Promise { + if (!isSandboxedPluginObject(pluginExports)) { + // we should monitor these cases. There should not be any plugins without a plugin export loaded inside the sandbox + return pluginExports; + } + + const pluginObject = await Promise.resolve(pluginExports.plugin); + + // intentionally not early exit to cover possible future cases + + // wrap panel component + if (Reflect.has(pluginObject, 'panel')) { + Reflect.set(pluginObject, 'panel', withSandboxWrapper(Reflect.get(pluginObject, 'panel'), meta.id)); + } + + // wrap datasource components + if (Reflect.has(pluginObject, 'components')) { + const components: Record = Reflect.get(pluginObject, 'components'); + Object.entries(components).forEach(([key, value]) => { + Reflect.set(components, key, withSandboxWrapper(value, meta.id)); + }); + Reflect.set(pluginObject, 'components', components); + } + + // wrap app components + if (Reflect.has(pluginObject, 'root')) { + Reflect.set(pluginObject, 'root', withSandboxWrapper(Reflect.get(pluginObject, 'root'), meta.id)); + } + + // extension components + if (Reflect.has(pluginObject, 'extensionConfigs')) { + const extensions: PluginExtensionConfig[] = Reflect.get(pluginObject, 'extensionConfigs'); + for (const extension of extensions) { + if (Reflect.has(extension, 'component')) { + Reflect.set(extension, 'component', withSandboxWrapper(Reflect.get(extension, 'component'), meta.id)); + } + } + Reflect.set(pluginObject, 'extensionConfigs', extensions); + } + + // config pages + if (Reflect.has(pluginObject, 'configPages')) { + const configPages: Record> = Reflect.get(pluginObject, 'configPages'); + for (const [key, value] of Object.entries(configPages)) { + if (!value.body || !isFunction(value.body)) { + continue; + } + Reflect.set(configPages, key, { + ...value, + body: withSandboxWrapper(value.body, meta.id), + }); + } + Reflect.set(pluginObject, 'configPages', configPages); + } + + return pluginExports; +} + +const withSandboxWrapper =

( + WrappedComponent: ComponentType

, + pluginId: string +): React.MemoExoticComponent> => { + const WithWrapper = React.memo((props: P) => { + return ( +

+ +
+ ); + }); + WithWrapper.displayName = `GrafanaSandbox(${WrappedComponent.displayName || WrappedComponent.name || 'Component'})`; + return WithWrapper; +}; diff --git a/public/app/features/plugins/sandbox/sandbox_plugin_loader.ts b/public/app/features/plugins/sandbox/sandbox_plugin_loader.ts index 80d5e86ff1f..f66209b2cde 100644 --- a/public/app/features/plugins/sandbox/sandbox_plugin_loader.ts +++ b/public/app/features/plugins/sandbox/sandbox_plugin_loader.ts @@ -1,17 +1,25 @@ import createVirtualEnvironment from '@locker/near-membrane-dom'; import { ProxyTarget } from '@locker/near-membrane-shared'; -import { GrafanaPlugin, PluginMeta } from '@grafana/data'; +import { PluginMeta } from '@grafana/data'; import { getPluginSettings } from '../pluginSettings'; import { getGeneralSandboxDistortionMap } from './distortion_map'; +import { + getSafeSandboxDomElement, + isDomElement, + isLiveTarget, + markDomElementStyleAsALiveTarget, +} from './document_sandbox'; import { sandboxPluginDependencies } from './plugin_dependencies'; +import { sandboxPluginComponents } from './sandbox_components'; +import { CompartmentDependencyModule, PluginFactoryFunction } from './types'; -type CompartmentDependencyModule = unknown; -type PluginFactoryFunction = (...args: CompartmentDependencyModule[]) => { - plugin: GrafanaPlugin; -}; +// Loads near membrane custom formatter for near membrane proxy objects. +if (process.env.NODE_ENV !== 'production') { + require('@locker/near-membrane-dom/custom-devtools-formatter'); +} const pluginImportCache = new Map>(); @@ -30,8 +38,22 @@ export async function importPluginModuleInSandbox({ pluginId }: { pluginId: stri async function doImportPluginModuleInSandbox(meta: PluginMeta): Promise { const generalDistortionMap = getGeneralSandboxDistortionMap(); - function distortionCallback(v: ProxyTarget): ProxyTarget { - return generalDistortionMap.get(v) ?? v; + /* + * this function is executed every time a plugin calls any DOM API + * it must be kept as lean and performant as possible and sync + */ + function distortionCallback(originalValue: ProxyTarget): ProxyTarget { + if (isDomElement(originalValue)) { + const element = getSafeSandboxDomElement(originalValue, meta.id); + // the element.style attribute should be a live target to work in chrome + markDomElementStyleAsALiveTarget(element); + return element; + } + const distortion = generalDistortionMap.get(originalValue); + if (distortion) { + return distortion(originalValue) as ProxyTarget; + } + return originalValue; } return new Promise(async (resolve, reject) => { @@ -40,6 +62,7 @@ async function doImportPluginModuleInSandbox(meta: PluginMeta): Promise // distortions are interceptors to modify the behavior of objects when // the code inside the sandbox tries to access them distortionCallback, + liveTargetCallback: isLiveTarget, // endowments are custom variables we make available to plugins in their window object endowments: Object.getOwnPropertyDescriptors({ // Plugins builds use the AMD module system. Their code consists @@ -47,11 +70,11 @@ async function doImportPluginModuleInSandbox(meta: PluginMeta): Promise // This is that `define` function the plugin will call. // More info about how AMD works https://github.com/amdjs/amdjs-api/blob/master/AMD.md // Plugins code normally use the "anonymous module" signature: define(depencies, factoryFunction) - define( + async define( idOrDependencies: string | string[], maybeDependencies: string[] | PluginFactoryFunction, maybeFactory?: PluginFactoryFunction - ): void { + ): Promise { let dependencies: string[]; let factory: PluginFactoryFunction; if (Array.isArray(idOrDependencies)) { @@ -65,10 +88,11 @@ async function doImportPluginModuleInSandbox(meta: PluginMeta): Promise try { const resolvedDeps = resolvePluginDependencies(dependencies); // execute the plugin's code - const pluginExports: { plugin: GrafanaPlugin } = factory.apply(null, resolvedDeps); + const pluginExportsRaw = factory.apply(null, resolvedDeps); // only after the plugin has been executed // we can return the plugin exports. // This is what grafana effectively gets. + const pluginExports = await sandboxPluginComponents(pluginExportsRaw, meta); resolve(pluginExports); } catch (e) { reject(new Error(`Could not execute plugin ${meta.id}: ` + e)); diff --git a/public/app/features/plugins/sandbox/types.ts b/public/app/features/plugins/sandbox/types.ts new file mode 100644 index 00000000000..d26f0688fa4 --- /dev/null +++ b/public/app/features/plugins/sandbox/types.ts @@ -0,0 +1,8 @@ +import { GrafanaPlugin } from '@grafana/data'; + +export type CompartmentDependencyModule = unknown; +export type PluginFactoryFunction = (...args: CompartmentDependencyModule[]) => SandboxedPluginObject; + +export type SandboxedPluginObject = { + plugin: GrafanaPlugin | Promise; +}; diff --git a/public/app/features/plugins/sandbox/utils.ts b/public/app/features/plugins/sandbox/utils.ts new file mode 100644 index 00000000000..78c8073746a --- /dev/null +++ b/public/app/features/plugins/sandbox/utils.ts @@ -0,0 +1,9 @@ +import { SandboxedPluginObject } from './types'; + +export function isSandboxedPluginObject(value: unknown): value is SandboxedPluginObject { + return !!value && typeof value === 'object' && value?.hasOwnProperty('plugin'); +} + +export function assertNever(x: never): never { + throw new Error(`Unexpected object: ${x}. This should never happen.`); +} diff --git a/scripts/webpack/webpack.common.js b/scripts/webpack/webpack.common.js index c9fad0b4d62..82776aeb81c 100644 --- a/scripts/webpack/webpack.common.js +++ b/scripts/webpack/webpack.common.js @@ -25,6 +25,13 @@ module.exports = { // some sub-dependencies use a different version of @emotion/react and generate warnings // in the browser about @emotion/react loaded twice. We want to only load it once '@emotion/react': require.resolve('@emotion/react'), + // due to our webpack configuration not understanding package.json `exports` + // correctly we must alias this package to the correct file + // the alternative to this alias is to copy-paste the file into our + // source code and miss out in updates + '@locker/near-membrane-dom/custom-devtools-formatter': require.resolve( + '@locker/near-membrane-dom/custom-devtools-formatter.js' + ), }, modules: ['node_modules', path.resolve('public')], fallback: { diff --git a/yarn.lock b/yarn.lock index 8fdca0aad65..e60f61658ff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5539,39 +5539,39 @@ __metadata: languageName: node linkType: hard -"@locker/near-membrane-base@npm:0.12.14": - version: 0.12.14 - resolution: "@locker/near-membrane-base@npm:0.12.14" +"@locker/near-membrane-base@npm:0.12.15": + version: 0.12.15 + resolution: "@locker/near-membrane-base@npm:0.12.15" dependencies: - "@locker/near-membrane-shared": 0.12.14 - checksum: f6878bbb59bf5241632e610c521be9138cc039d7983d2f9b3aeb670483446364a1b92d407f93fc1ae2834093d033f4223752cb9fb012a48f4b2eb985974a425c + "@locker/near-membrane-shared": 0.12.15 + checksum: 353b172bcd3a1d3790ca0baef4b8d0aabb7c1077cfb4452df9f4b36b44bafe054ab5d9b9cb4ec47deb6504bbaa4b516554a0406cbfa948cea2ceb4b4926a5d67 languageName: node linkType: hard -"@locker/near-membrane-dom@npm:^0.12.14": - version: 0.12.14 - resolution: "@locker/near-membrane-dom@npm:0.12.14" +"@locker/near-membrane-dom@npm:^0.12.15": + version: 0.12.15 + resolution: "@locker/near-membrane-dom@npm:0.12.15" dependencies: - "@locker/near-membrane-base": 0.12.14 - "@locker/near-membrane-shared": 0.12.14 - "@locker/near-membrane-shared-dom": 0.12.14 - checksum: fa8178feaa691fcd5c18405387b1296945da7b13ee8fade2efcbd1bbcd57dd17e2015ca70fe41807ae0c2892aa542286a5c07ac4c37c59032333642b7ae4628b + "@locker/near-membrane-base": 0.12.15 + "@locker/near-membrane-shared": 0.12.15 + "@locker/near-membrane-shared-dom": 0.12.15 + checksum: cd0d692f36665031f2485c8e4ff40e8cf051b7cecdf34b34171446585791f59b0b69b4570490918084178b37a26986f501b128b52c49b9b25aa8958e0cea15a8 languageName: node linkType: hard -"@locker/near-membrane-shared-dom@npm:0.12.14": - version: 0.12.14 - resolution: "@locker/near-membrane-shared-dom@npm:0.12.14" +"@locker/near-membrane-shared-dom@npm:0.12.15, @locker/near-membrane-shared-dom@npm:^0.12.15": + version: 0.12.15 + resolution: "@locker/near-membrane-shared-dom@npm:0.12.15" dependencies: - "@locker/near-membrane-shared": 0.12.14 - checksum: 7e3e6352b0f4aa3306e1b1f49f11b5ddc0c9820dd84d8317b68545d949fa583b2f7b3ab0c56446769e7d993431d1baf3245fd27ff255ee3e1c2c95d3e5c1876c + "@locker/near-membrane-shared": 0.12.15 + checksum: 2faabd8dc7d508d35f17f8573a78a5ec48aaa232e53791e182792516dec285e60c32e811ffd00bd1c916e56ba1e1ff7199fc8e4cdcf061b388a8ba6f22ad44c4 languageName: node linkType: hard -"@locker/near-membrane-shared@npm:0.12.14, @locker/near-membrane-shared@npm:^0.12.14": - version: 0.12.14 - resolution: "@locker/near-membrane-shared@npm:0.12.14" - checksum: f5e75ae422b5369ba5323a72e7cdb979ccc2178c7202a6e85b7e9adaf9971a367de413503cd1e743672798afec1f53836bdbe849acadfb640e41d93ee0125194 +"@locker/near-membrane-shared@npm:0.12.15, @locker/near-membrane-shared@npm:^0.12.15": + version: 0.12.15 + resolution: "@locker/near-membrane-shared@npm:0.12.15" + checksum: de5d44022148f7f9183781d50d591a40d8b54cc7692bbd54ee865c0a7ddb6bf15d465fb0e804ad86cbea9135cc7d31983eaed9c8cdbbe9dc94d9e74eaac75134 languageName: node linkType: hard @@ -18608,8 +18608,9 @@ __metadata: "@lezer/common": 1.0.2 "@lezer/highlight": 1.1.3 "@lezer/lr": 1.3.3 - "@locker/near-membrane-dom": ^0.12.14 - "@locker/near-membrane-shared": ^0.12.14 + "@locker/near-membrane-dom": ^0.12.15 + "@locker/near-membrane-shared": ^0.12.15 + "@locker/near-membrane-shared-dom": ^0.12.15 "@opentelemetry/api": 1.4.0 "@opentelemetry/exporter-collector": 0.25.0 "@opentelemetry/semantic-conventions": 1.14.0