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

pull/61517/head^2
Esteban Beltran 2 years ago committed by GitHub
parent 8a13ee3cd4
commit ed5a697825
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      .betterer.results
  2. 5
      package.json
  3. 9
      public/app/features/plugins/plugin_loader.ts
  4. 96
      public/app/features/plugins/sandbox/README.md
  5. 1
      public/app/features/plugins/sandbox/constants.ts
  6. 286
      public/app/features/plugins/sandbox/distortion_map.ts
  7. 108
      public/app/features/plugins/sandbox/document_sandbox.ts
  8. 102
      public/app/features/plugins/sandbox/sandbox_components.tsx
  9. 44
      public/app/features/plugins/sandbox/sandbox_plugin_loader.ts
  10. 8
      public/app/features/plugins/sandbox/types.ts
  11. 9
      public/app/features/plugins/sandbox/utils.ts
  12. 7
      scripts/webpack/webpack.common.js
  13. 47
      yarn.lock

@ -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.", "7"],
[0, 0, 0, "Do not use any type assertions.", "8"] [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": [ "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.", "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": [ "public/app/features/plugins/sql/components/visual-query-builder/AwesomeQueryBuilder.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "0"],

@ -271,8 +271,9 @@
"@lezer/common": "1.0.2", "@lezer/common": "1.0.2",
"@lezer/highlight": "1.1.3", "@lezer/highlight": "1.1.3",
"@lezer/lr": "1.3.3", "@lezer/lr": "1.3.3",
"@locker/near-membrane-dom": "^0.12.14", "@locker/near-membrane-dom": "^0.12.15",
"@locker/near-membrane-shared": "^0.12.14", "@locker/near-membrane-shared": "^0.12.15",
"@locker/near-membrane-shared-dom": "^0.12.15",
"@opentelemetry/api": "1.4.0", "@opentelemetry/api": "1.4.0",
"@opentelemetry/exporter-collector": "0.25.0", "@opentelemetry/exporter-collector": "0.25.0",
"@opentelemetry/semantic-conventions": "1.14.0", "@opentelemetry/semantic-conventions": "1.14.0",

@ -227,7 +227,14 @@ export async function importPluginModule({
} }
function isFrontendSandboxSupported(isAngular?: boolean): boolean { 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> { export function importDataSourcePlugin(meta: grafanaData.DataSourcePluginMeta): Promise<GenericDataSourcePlugin> {

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

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

@ -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(); 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() { 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 // sets distortion to protect iframe elements
@ -13,7 +86,9 @@ function distortIframeAttributes(distortions: DistortionMap) {
const descriptor = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, property); const descriptor = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, property);
if (descriptor) { if (descriptor) {
function fail() { 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) { if (descriptor.value) {
distortions.set(descriptor.value, fail); 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) { function distortConsole(distortions: DistortionMap) {
// distorts window.console to prefix it
const descriptor = Object.getOwnPropertyDescriptor(window, 'console'); const descriptor = Object.getOwnPropertyDescriptor(window, 'console');
if (descriptor?.value) { if (descriptor?.value) {
function sandboxLog(...args: unknown[]) { function sandboxLog(...args: unknown[]) {
@ -41,33 +116,214 @@ function distortConsole(distortions: DistortionMap) {
error: sandboxLog, error: sandboxLog,
info: sandboxLog, info: sandboxLog,
debug: sandboxLog, debug: sandboxLog,
table: sandboxLog,
}; };
distortions.set(descriptor.value, sandboxConsole); function getSandboxConsole() {
return sandboxConsole;
}
distortions.set(descriptor.value, getSandboxConsole);
} }
if (descriptor?.set) { if (descriptor?.set) {
distortions.set(descriptor.set, failToSet); distortions.set(descriptor.set, failToSet);
} }
} }
// set distortions to alert to always output to the console
function distortAlert(distortions: DistortionMap) { function distortAlert(distortions: DistortionMap) {
function getAlertDistortion() {
return function (...args: unknown[]) {
console.log(`[plugin]`, ...args);
};
}
const descriptor = Object.getOwnPropertyDescriptor(window, 'alert'); const descriptor = Object.getOwnPropertyDescriptor(window, 'alert');
if (descriptor?.value) { if (descriptor?.value) {
function sandboxAlert(...args: unknown[]) { distortions.set(descriptor.value, getAlertDistortion);
console.log(`[plugin]`, ...args);
}
distortions.set(descriptor.value, sandboxAlert);
} }
if (descriptor?.set) { if (descriptor?.set) {
distortions.set(descriptor.set, failToSet); distortions.set(descriptor.set, failToSet);
} }
} }
export function getGeneralSandboxDistortionMap() { function distortInnerHTML(distortions: DistortionMap) {
if (generalDistortionMap.size === 0) { function getInnerHTMLDistortion(originalMethod: unknown) {
distortIframeAttributes(generalDistortionMap); return function innerHTMLDistortion(this: HTMLElement, ...args: string[]) {
distortConsole(generalDistortionMap); for (const arg of args) {
distortAlert(generalDistortionMap); 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;
} }

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

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

@ -1,17 +1,25 @@
import createVirtualEnvironment from '@locker/near-membrane-dom'; import createVirtualEnvironment from '@locker/near-membrane-dom';
import { ProxyTarget } from '@locker/near-membrane-shared'; import { ProxyTarget } from '@locker/near-membrane-shared';
import { GrafanaPlugin, PluginMeta } from '@grafana/data'; import { PluginMeta } from '@grafana/data';
import { getPluginSettings } from '../pluginSettings'; import { getPluginSettings } from '../pluginSettings';
import { getGeneralSandboxDistortionMap } from './distortion_map'; import { getGeneralSandboxDistortionMap } from './distortion_map';
import {
getSafeSandboxDomElement,
isDomElement,
isLiveTarget,
markDomElementStyleAsALiveTarget,
} from './document_sandbox';
import { sandboxPluginDependencies } from './plugin_dependencies'; import { sandboxPluginDependencies } from './plugin_dependencies';
import { sandboxPluginComponents } from './sandbox_components';
import { CompartmentDependencyModule, PluginFactoryFunction } from './types';
type CompartmentDependencyModule = unknown; // Loads near membrane custom formatter for near membrane proxy objects.
type PluginFactoryFunction = (...args: CompartmentDependencyModule[]) => { if (process.env.NODE_ENV !== 'production') {
plugin: GrafanaPlugin; require('@locker/near-membrane-dom/custom-devtools-formatter');
}; }
const pluginImportCache = new Map<string, Promise<unknown>>(); 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> { async function doImportPluginModuleInSandbox(meta: PluginMeta): Promise<unknown> {
const generalDistortionMap = getGeneralSandboxDistortionMap(); 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) => { 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 // distortions are interceptors to modify the behavior of objects when
// the code inside the sandbox tries to access them // the code inside the sandbox tries to access them
distortionCallback, distortionCallback,
liveTargetCallback: isLiveTarget,
// endowments are custom variables we make available to plugins in their window object // endowments are custom variables we make available to plugins in their window object
endowments: Object.getOwnPropertyDescriptors({ endowments: Object.getOwnPropertyDescriptors({
// Plugins builds use the AMD module system. Their code consists // 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. // 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 // 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) // Plugins code normally use the "anonymous module" signature: define(depencies, factoryFunction)
define( async define(
idOrDependencies: string | string[], idOrDependencies: string | string[],
maybeDependencies: string[] | PluginFactoryFunction, maybeDependencies: string[] | PluginFactoryFunction,
maybeFactory?: PluginFactoryFunction maybeFactory?: PluginFactoryFunction
): void { ): Promise<void> {
let dependencies: string[]; let dependencies: string[];
let factory: PluginFactoryFunction; let factory: PluginFactoryFunction;
if (Array.isArray(idOrDependencies)) { if (Array.isArray(idOrDependencies)) {
@ -65,10 +88,11 @@ async function doImportPluginModuleInSandbox(meta: PluginMeta): Promise<unknown>
try { try {
const resolvedDeps = resolvePluginDependencies(dependencies); const resolvedDeps = resolvePluginDependencies(dependencies);
// execute the plugin's code // 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 // only after the plugin has been executed
// we can return the plugin exports. // we can return the plugin exports.
// This is what grafana effectively gets. // This is what grafana effectively gets.
const pluginExports = await sandboxPluginComponents(pluginExportsRaw, meta);
resolve(pluginExports); resolve(pluginExports);
} catch (e) { } catch (e) {
reject(new Error(`Could not execute plugin ${meta.id}: ` + e)); reject(new Error(`Could not execute plugin ${meta.id}: ` + e));

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

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

@ -25,6 +25,13 @@ module.exports = {
// some sub-dependencies use a different version of @emotion/react and generate warnings // 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 // in the browser about @emotion/react loaded twice. We want to only load it once
'@emotion/react': require.resolve('@emotion/react'), '@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')], modules: ['node_modules', path.resolve('public')],
fallback: { fallback: {

@ -5539,39 +5539,39 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@locker/near-membrane-base@npm:0.12.14": "@locker/near-membrane-base@npm:0.12.15":
version: 0.12.14 version: 0.12.15
resolution: "@locker/near-membrane-base@npm:0.12.14" resolution: "@locker/near-membrane-base@npm:0.12.15"
dependencies: dependencies:
"@locker/near-membrane-shared": 0.12.14 "@locker/near-membrane-shared": 0.12.15
checksum: f6878bbb59bf5241632e610c521be9138cc039d7983d2f9b3aeb670483446364a1b92d407f93fc1ae2834093d033f4223752cb9fb012a48f4b2eb985974a425c checksum: 353b172bcd3a1d3790ca0baef4b8d0aabb7c1077cfb4452df9f4b36b44bafe054ab5d9b9cb4ec47deb6504bbaa4b516554a0406cbfa948cea2ceb4b4926a5d67
languageName: node languageName: node
linkType: hard linkType: hard
"@locker/near-membrane-dom@npm:^0.12.14": "@locker/near-membrane-dom@npm:^0.12.15":
version: 0.12.14 version: 0.12.15
resolution: "@locker/near-membrane-dom@npm:0.12.14" resolution: "@locker/near-membrane-dom@npm:0.12.15"
dependencies: dependencies:
"@locker/near-membrane-base": 0.12.14 "@locker/near-membrane-base": 0.12.15
"@locker/near-membrane-shared": 0.12.14 "@locker/near-membrane-shared": 0.12.15
"@locker/near-membrane-shared-dom": 0.12.14 "@locker/near-membrane-shared-dom": 0.12.15
checksum: fa8178feaa691fcd5c18405387b1296945da7b13ee8fade2efcbd1bbcd57dd17e2015ca70fe41807ae0c2892aa542286a5c07ac4c37c59032333642b7ae4628b checksum: cd0d692f36665031f2485c8e4ff40e8cf051b7cecdf34b34171446585791f59b0b69b4570490918084178b37a26986f501b128b52c49b9b25aa8958e0cea15a8
languageName: node languageName: node
linkType: hard linkType: hard
"@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.14 version: 0.12.15
resolution: "@locker/near-membrane-shared-dom@npm:0.12.14" resolution: "@locker/near-membrane-shared-dom@npm:0.12.15"
dependencies: dependencies:
"@locker/near-membrane-shared": 0.12.14 "@locker/near-membrane-shared": 0.12.15
checksum: 7e3e6352b0f4aa3306e1b1f49f11b5ddc0c9820dd84d8317b68545d949fa583b2f7b3ab0c56446769e7d993431d1baf3245fd27ff255ee3e1c2c95d3e5c1876c checksum: 2faabd8dc7d508d35f17f8573a78a5ec48aaa232e53791e182792516dec285e60c32e811ffd00bd1c916e56ba1e1ff7199fc8e4cdcf061b388a8ba6f22ad44c4
languageName: node languageName: node
linkType: hard linkType: hard
"@locker/near-membrane-shared@npm:0.12.14, @locker/near-membrane-shared@npm:^0.12.14": "@locker/near-membrane-shared@npm:0.12.15, @locker/near-membrane-shared@npm:^0.12.15":
version: 0.12.14 version: 0.12.15
resolution: "@locker/near-membrane-shared@npm:0.12.14" resolution: "@locker/near-membrane-shared@npm:0.12.15"
checksum: f5e75ae422b5369ba5323a72e7cdb979ccc2178c7202a6e85b7e9adaf9971a367de413503cd1e743672798afec1f53836bdbe849acadfb640e41d93ee0125194 checksum: de5d44022148f7f9183781d50d591a40d8b54cc7692bbd54ee865c0a7ddb6bf15d465fb0e804ad86cbea9135cc7d31983eaed9c8cdbbe9dc94d9e74eaac75134
languageName: node languageName: node
linkType: hard linkType: hard
@ -18608,8 +18608,9 @@ __metadata:
"@lezer/common": 1.0.2 "@lezer/common": 1.0.2
"@lezer/highlight": 1.1.3 "@lezer/highlight": 1.1.3
"@lezer/lr": 1.3.3 "@lezer/lr": 1.3.3
"@locker/near-membrane-dom": ^0.12.14 "@locker/near-membrane-dom": ^0.12.15
"@locker/near-membrane-shared": ^0.12.14 "@locker/near-membrane-shared": ^0.12.15
"@locker/near-membrane-shared-dom": ^0.12.15
"@opentelemetry/api": 1.4.0 "@opentelemetry/api": 1.4.0
"@opentelemetry/exporter-collector": 0.25.0 "@opentelemetry/exporter-collector": 0.25.0
"@opentelemetry/semantic-conventions": 1.14.0 "@opentelemetry/semantic-conventions": 1.14.0

Loading…
Cancel
Save