Sandbox: Add support for webpack dynamic imports (#71714)

pull/71959/head
Esteban Beltran 2 years ago committed by GitHub
parent 47f70bdb00
commit e8e3f81e38
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 88
      public/app/features/plugins/sandbox/code_loader.ts
  2. 2
      public/app/features/plugins/sandbox/constants.ts
  3. 54
      public/app/features/plugins/sandbox/distortion_map.ts
  4. 97
      public/app/features/plugins/sandbox/sandbox_plugin_loader.ts
  5. 4
      public/app/features/plugins/sandbox/types.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<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>;

Loading…
Cancel
Save