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, {}); } } export function recursivePatchObjectAsLiveTarget(obj: unknown) { if (!obj) { return; } if (Array.isArray(obj)) { obj.forEach(recursivePatchObjectAsLiveTarget); unconditionallyPatchObjectAsLiveTarget(obj); } else if (typeof obj === 'object') { Object.values(obj).forEach(recursivePatchObjectAsLiveTarget); unconditionallyPatchObjectAsLiveTarget(obj); } } function unconditionallyPatchObjectAsLiveTarget(obj: unknown) { if (!obj) { return; } // do not patch it twice if (Object.hasOwn(obj, SANDBOX_LIVE_VALUE)) { return obj; } Reflect.defineProperty(obj, SANDBOX_LIVE_VALUE, {}); return obj; } /** * 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) { 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, }); }