grafana/public/app/features/plugins/sandbox/document_sandbox.ts
Esteban Beltran 7107ba0104
Sandbox: Patch history.replaceState to make it work inside the sandbox (#76255)
* Patch history.replaceState api instead of doing a live distortion

* Add better patching mechanism

* Remove console log
2023-11-07 09:09:17 +01:00

211 lines
6.5 KiB
TypeScript

import { isNearMembraneProxy, ProxyTarget } from '@locker/near-membrane-shared';
import { cloneDeep } from 'lodash';
import Prism from 'prismjs';
import { DataSourceApi } from '@grafana/data';
import { config } from '@grafana/runtime';
import { forbiddenElements } from './constants';
import { isReactClassComponent, logWarning } from './utils';
// IMPORTANT: NEVER export this symbol from a public (e.g `@grafana/*`) package
const SANDBOX_LIVE_VALUE = Symbol.for('@@SANDBOX_LIVE_VALUE');
const monitorOnly = Boolean(config.featureToggles.frontendSandboxMonitorOnly);
export function getSafeSandboxDomElement(element: Element, pluginId: string): Element {
const nodeName = Reflect.get(element, 'nodeName');
// 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)) {
logWarning('<' + nodeName + '> is not allowed in sandboxed plugins', {
pluginId,
param: nodeName,
});
if (!monitorOnly) {
throw new Error('<' + nodeName + '> is not allowed in sandboxed plugins');
}
}
// allow elements inside the sandbox or the sandbox body
if (isDomElementInsideSandbox(element, pluginId)) {
return element;
}
if (element.parentNode === document.body || element.closest('#reactRoot') === null) {
return element;
}
if (!monitorOnly) {
// any other element gets a mock
const mockElement = document.createElement(nodeName);
mockElement.dataset.grafanaPluginSandboxElement = 'true';
// we are not logging this because a high number of warnings can be generated
return mockElement;
} else {
return element;
}
}
export function isDomElement(obj: unknown): obj is Element {
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) {
const style = Reflect.get(el, 'style');
if (!Object.hasOwn(style, SANDBOX_LIVE_VALUE)) {
Reflect.defineProperty(style, SANDBOX_LIVE_VALUE, {});
}
}
/**
* Some specific near membrane proxies interfere with plugins
* an example of this is React class components state and their fast life cycles
* with cached objects.
*
* This function marks an object as a live target inside the sandbox
* but not all objects, only the ones that are allowed to be modified
*/
export function patchObjectAsLiveTarget(obj: unknown) {
if (!obj) {
return;
}
// do not patch it twice
if (Object.hasOwn(obj, SANDBOX_LIVE_VALUE)) {
return;
}
if (
// only for proxies
isNearMembraneProxy(obj) &&
// do not patch functions
!(obj instanceof Function) &&
// conditions for allowed objects
// react class components
(isReactClassComponent(obj) || obj instanceof DataSourceApi)
) {
Reflect.defineProperty(obj, SANDBOX_LIVE_VALUE, {});
} else {
// prismjs languages are defined by directly modifying the prism.languages objects.
// Plugins inside the sandbox can't modify objects from the blue realm and prismjs.languages
// is one of them.
// Marking it as a live target allows plugins inside the sandbox to modify the object directly
// and make syntax work again.
if (obj === Prism.languages) {
Object.defineProperty(obj, 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;
}
let nativeAPIsPatched = false;
export function patchWebAPIs() {
if (!nativeAPIsPatched) {
nativeAPIsPatched = true;
patchHistoryReplaceState();
}
}
/*
* window.history.replaceState is a native API that won't work with proxies
* so we need to patch it to unwrap any possible proxies you pass to it.
*
* Why can't we directly distord window.history.replaceState calls inside plugins?
*
* We can. Except that plugins don't call window.history.replaceState directly they
* instead use the history object from react-router.
*
* react-router is a runtime dependency and it is executed in the blue realm
* and calls window.history.replaceState directly where the sandbox is not involved at all
*
* It is most likely this "original" function is not really the native function because
* `useLocation` from `react-use` patches this function before the sandbox kicks in.
*
* Regarding the performance impact of this cloneDeep. The structures passed to history.replaceState
* are minimalistic and its impact will be neglegible.
*/
function patchHistoryReplaceState() {
const original = window.history.replaceState;
Object.defineProperty(window.history, 'replaceState', {
value: function (...args: Parameters<typeof window.history.replaceState>) {
let newArgs = args;
try {
newArgs = cloneDeep(args);
} catch (e) {
logWarning('Error cloning args in window.history.replaceState', {
error: String(e),
});
}
return Reflect.apply(original, this, newArgs);
},
writable: true,
configurable: true,
enumerable: false,
});
}