Plugins: Sandbox frontend plugins DOM access. (#69246)

This commit is contained in:
Esteban Beltran 2023-06-21 14:49:22 +02:00 committed by GitHub
parent 8a13ee3cd4
commit ed5a697825
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 676 additions and 52 deletions

View File

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

View File

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

View File

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

View File

@ -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<br> run a fetch() request
red ->> proxy: get fetch function
Note over red, proxy: This is not the fetch call itself, this is<br>"give me the function object I'll use<br> 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<br> be called as the "fetch" function
red ->> red: run fetch
Note right of red: Code runs a fetch() request<br> using the distorted fetch function
```

View File

@ -0,0 +1 @@
export const forbiddenElements = ['script', 'iframe'];

View File

@ -1,8 +1,81 @@
type DistortionMap = Map<unknown, unknown>;
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, (originalAttrOrMethod: unknown) => 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;
}

View File

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

View File

@ -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<SandboxedPluginObject | unknown> {
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<string, ComponentType> = 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<string, PluginConfigPage<any>> = 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 = <P extends object>(
WrappedComponent: ComponentType<P>,
pluginId: string
): React.MemoExoticComponent<FC<P>> => {
const WithWrapper = React.memo((props: P) => {
return (
<div data-plugin-sandbox={pluginId}>
<WrappedComponent {...props} />
</div>
);
});
WithWrapper.displayName = `GrafanaSandbox(${WrappedComponent.displayName || WrappedComponent.name || 'Component'})`;
return WithWrapper;
};

View File

@ -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<string, Promise<unknown>>();
@ -30,8 +38,22 @@ export async function importPluginModuleInSandbox({ pluginId }: { pluginId: stri
async function doImportPluginModuleInSandbox(meta: PluginMeta): Promise<unknown> {
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<unknown>
// 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<unknown>
// 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<void> {
let dependencies: string[];
let factory: PluginFactoryFunction;
if (Array.isArray(idOrDependencies)) {
@ -65,10 +88,11 @@ async function doImportPluginModuleInSandbox(meta: PluginMeta): Promise<unknown>
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));

View File

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

View File

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

View File

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

View File

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