mirror of
https://github.com/grafana/grafana.git
synced 2025-07-31 05:43:00 +08:00
Sandbox: Add support for webpack dynamic imports (#71714)
This commit is contained in:
88
public/app/features/plugins/sandbox/code_loader.ts
Normal file
88
public/app/features/plugins/sandbox/code_loader.ts
Normal file
@ -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<string> {
|
||||
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;
|
||||
}
|
@ -1 +1 @@
|
||||
export const forbiddenElements = ['script', 'iframe'];
|
||||
export const forbiddenElements = ['iframe'];
|
||||
|
@ -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, (originalAttrOrMethod: unknown, pluginId: string) => 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);
|
||||
}
|
||||
|
@ -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<unknown> {
|
||||
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<unknown>
|
||||
});
|
||||
}
|
||||
|
||||
async function getPluginCode(meta: PluginMeta): Promise<string> {
|
||||
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[] = [];
|
||||
|
@ -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<GrafanaPlugin>;
|
||||
};
|
||||
|
||||
export type SandboxEnvironment = ReturnType<typeof createVirtualEnvironment>;
|
||||
|
Reference in New Issue
Block a user