diff --git a/public/app/features/plugins/sandbox/code_loader.ts b/public/app/features/plugins/sandbox/code_loader.ts new file mode 100644 index 00000000000..e8edbd29760 --- /dev/null +++ b/public/app/features/plugins/sandbox/code_loader.ts @@ -0,0 +1,88 @@ +import { PluginMeta } from '@grafana/data'; + +import { getPluginCdnResourceUrl, extractPluginIdVersionFromUrl, transformPluginSourceForCDN } from '../cdn/utils'; +import { PLUGIN_CDN_URL_KEY } from '../constants'; + +import { SandboxEnvironment } from './types'; + +function isSameDomainAsHost(url: string): boolean { + const locationUrl = new URL(window.location.href); + const paramUrl = new URL(url); + return locationUrl.host === paramUrl.host; +} + +export async function loadScriptIntoSandbox(url: string, meta: PluginMeta, sandboxEnv: SandboxEnvironment) { + let scriptCode = ''; + + // same-domain + if (isSameDomainAsHost(url)) { + const response = await fetch(url); + scriptCode = await response.text(); + scriptCode = patchPluginSourceMap(meta, scriptCode); + + // cdn loaded + } else if (url.includes(PLUGIN_CDN_URL_KEY)) { + const response = await fetch(url); + scriptCode = await response.text(); + const pluginUrl = getPluginCdnResourceUrl(`/public/${meta.module}`) + '.js'; + const { version } = extractPluginIdVersionFromUrl(pluginUrl); + scriptCode = transformPluginSourceForCDN({ + pluginId: meta.id, + version, + source: scriptCode, + }); + } + + if (scriptCode.length === 0) { + throw new Error('Only same domain scripts are allowed in sandboxed plugins'); + } + + sandboxEnv.evaluate(scriptCode); +} + +export async function getPluginCode(meta: PluginMeta): Promise { + if (meta.module.includes(`${PLUGIN_CDN_URL_KEY}/`)) { + // should load plugin from a CDN + const pluginUrl = getPluginCdnResourceUrl(`/public/${meta.module}`) + '.js'; + const response = await fetch(pluginUrl); + let pluginCode = await response.text(); + const { version } = extractPluginIdVersionFromUrl(pluginUrl); + pluginCode = transformPluginSourceForCDN({ + pluginId: meta.id, + version, + source: pluginCode, + }); + return pluginCode; + } else { + //local plugin loading + const response = await fetch('public/' + meta.module + '.js'); + let pluginCode = await response.text(); + pluginCode = patchPluginSourceMap(meta, pluginCode); + return pluginCode; + } +} + +/** + * Patches the plugin's module.js source code references to sourcemaps to include the full url + * of the module.js file instead of the regular relative reference. + * + * Because the plugin module.js code is loaded via fetch and then "eval" as a string + * it can't find the references to the module.js.map directly and we need to patch it + * to point to the correct location + */ +function patchPluginSourceMap(meta: PluginMeta, pluginCode: string): string { + // skips inlined and files without source maps + if (pluginCode.includes('//# sourceMappingURL=module.js.map')) { + let replaceWith = ''; + // make sure we don't add the sourceURL twice + if (!pluginCode.includes('//# sourceURL') || !pluginCode.includes('//@ sourceUrl')) { + replaceWith += `//# sourceURL=module.js\n`; + } + // modify the source map url to point to the correct location + const sourceCodeMapUrl = `/public/${meta.module}.js.map`; + replaceWith += `//# sourceMappingURL=${sourceCodeMapUrl}`; + + return pluginCode.replace('//# sourceMappingURL=module.js.map', replaceWith); + } + return pluginCode; +} diff --git a/public/app/features/plugins/sandbox/constants.ts b/public/app/features/plugins/sandbox/constants.ts index 3d07fbce3b9..d215aa22a75 100644 --- a/public/app/features/plugins/sandbox/constants.ts +++ b/public/app/features/plugins/sandbox/constants.ts @@ -1 +1 @@ -export const forbiddenElements = ['script', 'iframe']; +export const forbiddenElements = ['iframe']; diff --git a/public/app/features/plugins/sandbox/distortion_map.ts b/public/app/features/plugins/sandbox/distortion_map.ts index 999e5308da6..56ec93f8058 100644 --- a/public/app/features/plugins/sandbox/distortion_map.ts +++ b/public/app/features/plugins/sandbox/distortion_map.ts @@ -1,8 +1,11 @@ import { cloneDeep, isFunction } from 'lodash'; +import { PluginMeta } from '@grafana/data'; import { config } from '@grafana/runtime'; +import { loadScriptIntoSandbox } from './code_loader'; import { forbiddenElements } from './constants'; +import { SandboxEnvironment } from './types'; import { logWarning } from './utils'; /** @@ -56,7 +59,10 @@ import { logWarning } from './utils'; * The code in this file defines that generalDistortionMap. */ -type DistortionMap = Map unknown>; +type DistortionMap = Map< + unknown, + (originalAttrOrMethod: unknown, pluginMeta: PluginMeta, sandboxEnv?: SandboxEnvironment) => unknown +>; const generalDistortionMap: DistortionMap = new Map(); const monitorOnly = Boolean(config.featureToggles.frontendSandboxMonitorOnly); @@ -77,9 +83,9 @@ export function getGeneralSandboxDistortionMap() { return generalDistortionMap; } -function failToSet(originalAttrOrMethod: unknown, pluginId: string) { - logWarning(`Plugin ${pluginId} tried to set a sandboxed property`, { - pluginId, +function failToSet(originalAttrOrMethod: unknown, meta: PluginMeta) { + logWarning(`Plugin ${meta.id} tried to set a sandboxed property`, { + pluginId: meta.id, attrOrMethod: String(originalAttrOrMethod), entity: 'window', }); @@ -98,7 +104,8 @@ function distortIframeAttributes(distortions: DistortionMap) { for (const property of iframeHtmlForbiddenProperties) { const descriptor = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, property); if (descriptor) { - function fail(originalAttrOrMethod: unknown, pluginId: string) { + function fail(originalAttrOrMethod: unknown, meta: PluginMeta) { + const pluginId = meta.id; logWarning(`Plugin ${pluginId} tried to access iframe.${property}`, { pluginId, attrOrMethod: property, @@ -131,7 +138,8 @@ function distortIframeAttributes(distortions: DistortionMap) { function distortConsole(distortions: DistortionMap) { const descriptor = Object.getOwnPropertyDescriptor(window, 'console'); if (descriptor?.value) { - function getSandboxConsole(originalAttrOrMethod: unknown, pluginId: string) { + function getSandboxConsole(originalAttrOrMethod: unknown, meta: PluginMeta) { + const pluginId = meta.id; // we don't monitor the console because we expect a high volume of calls if (monitorOnly) { return originalAttrOrMethod; @@ -159,7 +167,8 @@ function distortConsole(distortions: DistortionMap) { // set distortions to alert to always output to the console function distortAlert(distortions: DistortionMap) { - function getAlertDistortion(originalAttrOrMethod: unknown, pluginId: string) { + function getAlertDistortion(originalAttrOrMethod: unknown, meta: PluginMeta) { + const pluginId = meta.id; logWarning(`Plugin ${pluginId} accessed window.alert`, { pluginId, attrOrMethod: 'alert', @@ -184,7 +193,8 @@ function distortAlert(distortions: DistortionMap) { } function distortInnerHTML(distortions: DistortionMap) { - function getInnerHTMLDistortion(originalMethod: unknown, pluginId: string) { + function getInnerHTMLDistortion(originalMethod: unknown, meta: PluginMeta) { + const pluginId = meta.id; return function innerHTMLDistortion(this: HTMLElement, ...args: string[]) { for (const arg of args) { const lowerCase = arg?.toLowerCase() || ''; @@ -228,7 +238,8 @@ function distortInnerHTML(distortions: DistortionMap) { } function distortCreateElement(distortions: DistortionMap) { - function getCreateElementDistortion(originalMethod: unknown, pluginId: string) { + function getCreateElementDistortion(originalMethod: unknown, meta: PluginMeta) { + const pluginId = meta.id; return function createElementDistortion(this: HTMLElement, arg?: string, options?: unknown) { if (arg && forbiddenElements.includes(arg)) { logWarning(`Plugin ${pluginId} tried to create ${arg}`, { @@ -253,7 +264,8 @@ function distortCreateElement(distortions: DistortionMap) { } function distortInsert(distortions: DistortionMap) { - function getInsertDistortion(originalMethod: unknown, pluginId: string) { + function getInsertDistortion(originalMethod: unknown, meta: PluginMeta) { + const pluginId = meta.id; return function insertChildDistortion(this: HTMLElement, node?: Node, ref?: Node) { const nodeType = node?.nodeName?.toLowerCase() || ''; @@ -274,7 +286,8 @@ function distortInsert(distortions: DistortionMap) { }; } - function getinsertAdjacentElementDistortion(originalMethod: unknown, pluginId: string) { + function getinsertAdjacentElementDistortion(originalMethod: unknown, meta: PluginMeta) { + const pluginId = meta.id; return function insertAdjacentElementDistortion(this: HTMLElement, position?: string, node?: Node) { const nodeType = node?.nodeName?.toLowerCase() || ''; if (node && forbiddenElements.includes(nodeType)) { @@ -315,7 +328,8 @@ function distortInsert(distortions: DistortionMap) { // 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, pluginId: string) { + function getAppendDistortion(originalMethod: unknown, meta: PluginMeta) { + const pluginId = meta.id; return function appendDistortion(this: HTMLElement, ...args: Node[]) { let acceptedNodes = args; const filteredAcceptedNodes = args?.filter((node) => !forbiddenElements.includes(node.nodeName.toLowerCase())); @@ -341,7 +355,8 @@ function distortAppend(distortions: DistortionMap) { } // appendChild accepts a single node to add https://developer.mozilla.org/en-US/docs/Web/API/Node/appendChild - function getAppendChildDistortion(originalMethod: unknown, pluginId: string) { + function getAppendChildDistortion(originalMethod: unknown, meta: PluginMeta, sandboxEnv?: SandboxEnvironment) { + const pluginId = meta.id; return function appendChildDistortion(this: HTMLElement, arg?: Node) { const nodeType = arg?.nodeName?.toLowerCase() || ''; if (arg && forbiddenElements.includes(nodeType)) { @@ -356,6 +371,19 @@ function distortAppend(distortions: DistortionMap) { return document.createDocumentFragment(); } } + // if the node is a script, load it into the sandbox + // this allows webpack chunks to be loaded into the sandbox + // loadScriptIntoSandbox has restrictions on what scripts can be loaded + if (sandboxEnv && arg && nodeType === 'script' && arg instanceof HTMLScriptElement) { + loadScriptIntoSandbox(arg.src, meta, sandboxEnv) + .then(() => { + arg.onload?.call(arg, new Event('load')); + }) + .catch((err) => { + arg.onerror?.call(arg, new ErrorEvent('error', { error: err })); + }); + return undefined; + } if (isFunction(originalMethod)) { return originalMethod.call(this, arg); } diff --git a/public/app/features/plugins/sandbox/sandbox_plugin_loader.ts b/public/app/features/plugins/sandbox/sandbox_plugin_loader.ts index fd6ec28da17..0e72eb730b3 100644 --- a/public/app/features/plugins/sandbox/sandbox_plugin_loader.ts +++ b/public/app/features/plugins/sandbox/sandbox_plugin_loader.ts @@ -3,10 +3,9 @@ import { ProxyTarget } from '@locker/near-membrane-shared'; import { PluginMeta } from '@grafana/data'; -import { extractPluginIdVersionFromUrl, getPluginCdnResourceUrl, transformPluginSourceForCDN } from '../cdn/utils'; -import { PLUGIN_CDN_URL_KEY } from '../constants'; import { getPluginSettings } from '../pluginSettings'; +import { getPluginCode } from './code_loader'; import { getGeneralSandboxDistortionMap } from './distortion_map'; import { getSafeSandboxDomElement, @@ -17,7 +16,7 @@ import { } from './document_sandbox'; import { sandboxPluginDependencies } from './plugin_dependencies'; import { sandboxPluginComponents } from './sandbox_components'; -import { CompartmentDependencyModule, PluginFactoryFunction } from './types'; +import { CompartmentDependencyModule, PluginFactoryFunction, SandboxEnvironment } from './types'; import { logError } from './utils'; // Loads near membrane custom formatter for near membrane proxy objects. @@ -45,31 +44,30 @@ export async function importPluginModuleInSandbox({ pluginId }: { pluginId: stri } async function doImportPluginModuleInSandbox(meta: PluginMeta): Promise { - const generalDistortionMap = getGeneralSandboxDistortionMap(); - - /* - * 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; - } else { - patchObjectAsLiveTarget(originalValue); - } - const distortion = generalDistortionMap.get(originalValue); - if (distortion) { - return distortion(originalValue, meta.id) as ProxyTarget; - } - return originalValue; - } - return new Promise(async (resolve, reject) => { + const generalDistortionMap = getGeneralSandboxDistortionMap(); + let sandboxEnvironment: SandboxEnvironment; + /* + * 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; + } else { + patchObjectAsLiveTarget(originalValue); + } + const distortion = generalDistortionMap.get(originalValue); + if (distortion) { + return distortion(originalValue, meta, sandboxEnvironment) as ProxyTarget; + } + return originalValue; + } // each plugin has its own sandbox - const sandboxEnvironment = createVirtualEnvironment(window, { + sandboxEnvironment = createVirtualEnvironment(window, { // distortions are interceptors to modify the behavior of objects when // the code inside the sandbox tries to access them distortionCallback, @@ -158,28 +156,6 @@ async function doImportPluginModuleInSandbox(meta: PluginMeta): Promise }); } -async function getPluginCode(meta: PluginMeta): Promise { - if (meta.module.includes(`${PLUGIN_CDN_URL_KEY}/`)) { - // should load plugin from a CDN - const pluginUrl = getPluginCdnResourceUrl(`/public/${meta.module}`) + '.js'; - const response = await fetch(pluginUrl); - let pluginCode = await response.text(); - const { version } = extractPluginIdVersionFromUrl(pluginUrl); - pluginCode = transformPluginSourceForCDN({ - pluginId: meta.id, - version, - source: pluginCode, - }); - return pluginCode; - } else { - //local plugin loading - const response = await fetch('public/' + meta.module + '.js'); - let pluginCode = await response.text(); - pluginCode = patchPluginSourceMap(meta, pluginCode); - return pluginCode; - } -} - function getActivityErrorHandler(pluginId: string) { return async function error(proxyError?: Error & { sandboxError?: boolean }) { if (!proxyError) { @@ -203,31 +179,6 @@ function getActivityErrorHandler(pluginId: string) { }; } -/** - * Patches the plugin's module.js source code references to sourcemaps to include the full url - * of the module.js file instead of the regular relative reference. - * - * Because the plugin module.js code is loaded via fetch and then "eval" as a string - * it can't find the references to the module.js.map directly and we need to patch it - * to point to the correct location - */ -function patchPluginSourceMap(meta: PluginMeta, pluginCode: string): string { - // skips inlined and files without source maps - if (pluginCode.includes('//# sourceMappingURL=module.js.map')) { - let replaceWith = ''; - // make sure we don't add the sourceURL twice - if (!pluginCode.includes('//# sourceURL') || !pluginCode.includes('//@ sourceUrl')) { - replaceWith += `//# sourceURL=module.js\n`; - } - // modify the source map url to point to the correct location - const sourceCodeMapUrl = `/public/${meta.module}.js.map`; - replaceWith += `//# sourceMappingURL=${sourceCodeMapUrl}`; - - return pluginCode.replace('//# sourceMappingURL=module.js.map', replaceWith); - } - return pluginCode; -} - function resolvePluginDependencies(deps: string[]) { // resolve dependencies const resolvedDeps: CompartmentDependencyModule[] = []; diff --git a/public/app/features/plugins/sandbox/types.ts b/public/app/features/plugins/sandbox/types.ts index d26f0688fa4..aacd915ce0d 100644 --- a/public/app/features/plugins/sandbox/types.ts +++ b/public/app/features/plugins/sandbox/types.ts @@ -1,3 +1,5 @@ +import createVirtualEnvironment from '@locker/near-membrane-dom'; + import { GrafanaPlugin } from '@grafana/data'; export type CompartmentDependencyModule = unknown; @@ -6,3 +8,5 @@ export type PluginFactoryFunction = (...args: CompartmentDependencyModule[]) => export type SandboxedPluginObject = { plugin: GrafanaPlugin | Promise; }; + +export type SandboxEnvironment = ReturnType;