mirror of
https://github.com/grafana/grafana.git
synced 2024-11-21 16:38:03 -06:00
Plugins: Sandbox frontend plugins DOM access. (#69246)
This commit is contained in:
parent
8a13ee3cd4
commit
ed5a697825
@ -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"],
|
||||
|
@ -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",
|
||||
|
@ -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> {
|
||||
|
96
public/app/features/plugins/sandbox/README.md
Normal file
96
public/app/features/plugins/sandbox/README.md
Normal 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
|
||||
```
|
1
public/app/features/plugins/sandbox/constants.ts
Normal file
1
public/app/features/plugins/sandbox/constants.ts
Normal file
@ -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();
|
||||
|
||||
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;
|
||||
}
|
||||
|
108
public/app/features/plugins/sandbox/document_sandbox.ts
Normal file
108
public/app/features/plugins/sandbox/document_sandbox.ts
Normal 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;
|
||||
}
|
102
public/app/features/plugins/sandbox/sandbox_components.tsx
Normal file
102
public/app/features/plugins/sandbox/sandbox_components.tsx
Normal 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;
|
||||
};
|
@ -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));
|
||||
|
8
public/app/features/plugins/sandbox/types.ts
Normal file
8
public/app/features/plugins/sandbox/types.ts
Normal 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>;
|
||||
};
|
9
public/app/features/plugins/sandbox/utils.ts
Normal file
9
public/app/features/plugins/sandbox/utils.ts
Normal 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.`);
|
||||
}
|
@ -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: {
|
||||
|
47
yarn.lock
47
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
|
||||
|
Loading…
Reference in New Issue
Block a user