Plugins: Add Initial implementation for frontend plugins sandboxing (#68889)

This commit is contained in:
Esteban Beltran 2023-06-05 10:51:36 +02:00 committed by GitHub
parent 6c7b17b59f
commit 1ed4c0382b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 380 additions and 11 deletions

View File

@ -2988,6 +2988,10 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "7"],
[0, 0, 0, "Do not use any type assertions.", "8"]
],
"public/app/features/plugins/sandbox/sandbox_plugin_loader.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
],
"public/app/features/plugins/sql/components/visual-query-builder/AwesomeQueryBuilder.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],

View File

@ -111,6 +111,7 @@ Alpha features might be changed or removed without prior notice.
| `pluginsAPIManifestKey` | Use grafana.com API to retrieve the public manifest key |
| `extraThemes` | Enables extra themes |
| `lokiPredefinedOperations` | Adds predefined query operations to Loki query editor |
| `pluginsFrontendSandbox` | Enables the plugins frontend sandbox |
## Development feature toggles

View File

@ -29,6 +29,8 @@ module.exports = {
'\\.svg': '<rootDir>/public/test/mocks/svg.ts',
'\\.css': '<rootDir>/public/test/mocks/style.ts',
'monaco-editor/esm/vs/editor/editor.api': '<rootDir>/public/test/mocks/monaco.ts',
// near-membrane-dom won't work in a nodejs environment.
'@locker/near-membrane-dom': '<rootDir>/public/test/mocks/nearMembraneDom.ts',
},
// Log the test results with dynamic Loki tags. Drone CI only
reporters: ['default', ['<rootDir>/public/test/log-reporter.js', { enable: process.env.DRONE === 'true' }]],

View File

@ -269,6 +269,8 @@
"@lezer/common": "1.0.2",
"@lezer/highlight": "1.1.3",
"@lezer/lr": "1.3.3",
"@locker/near-membrane-dom": "^0.12.14",
"@locker/near-membrane-shared": "^0.12.14",
"@opentelemetry/api": "1.4.0",
"@opentelemetry/exporter-collector": "0.25.0",
"@opentelemetry/semantic-conventions": "1.9.1",

View File

@ -98,4 +98,5 @@ export interface FeatureToggles {
dataSourcePageHeader?: boolean;
extraThemes?: boolean;
lokiPredefinedOperations?: boolean;
pluginsFrontendSandbox?: boolean;
}

View File

@ -81,6 +81,7 @@ export interface PluginMeta<T extends KeyValue = {}> {
signatureType?: PluginSignatureType;
signatureOrg?: string;
live?: boolean;
angularDetected?: boolean;
}
interface PluginDependencyInfo {

View File

@ -29,6 +29,7 @@ export type AppPluginConfig = {
path: string;
version: string;
preload: boolean;
angularDetected?: boolean;
};
export class GrafanaBootConfig implements GrafanaConfig {

View File

@ -20,12 +20,13 @@ type PluginSetting struct {
SecureJsonFields map[string]bool `json:"secureJsonFields"`
DefaultNavUrl string `json:"defaultNavUrl"`
LatestVersion string `json:"latestVersion"`
HasUpdate bool `json:"hasUpdate"`
State plugins.ReleaseState `json:"state"`
Signature plugins.SignatureStatus `json:"signature"`
SignatureType plugins.SignatureType `json:"signatureType"`
SignatureOrg string `json:"signatureOrg"`
LatestVersion string `json:"latestVersion"`
HasUpdate bool `json:"hasUpdate"`
State plugins.ReleaseState `json:"state"`
Signature plugins.SignatureStatus `json:"signature"`
SignatureType plugins.SignatureType `json:"signatureType"`
SignatureOrg string `json:"signatureOrg"`
AngularDetected bool `json:"angularDetected"`
}
type PluginListItem struct {

View File

@ -196,6 +196,7 @@ func (hs *HTTPServer) GetPluginSettingByID(c *contextmodel.ReqContext) response.
SignatureType: plugin.SignatureType,
SignatureOrg: plugin.SignatureOrg,
SecureJsonFields: map[string]bool{},
AngularDetected: plugin.AngularDetected,
}
if plugin.IsApp() {

View File

@ -542,5 +542,12 @@ var (
State: FeatureStateAlpha,
Owner: grafanaObservabilityLogsSquad,
},
{
Name: "pluginsFrontendSandbox",
Description: "Enables the plugins frontend sandbox",
State: FeatureStateAlpha,
FrontendOnly: true,
Owner: grafanaPluginsPlatformSquad,
},
}
)

View File

@ -79,3 +79,4 @@ enableDatagridEditing,beta,@grafana/grafana-bi-squad,false,false,false,true
dataSourcePageHeader,beta,@grafana/enterprise-datasources,false,false,false,true
extraThemes,alpha,@grafana/user-essentials,false,false,false,true
lokiPredefinedOperations,alpha,@grafana/observability-logs,false,false,false,true
pluginsFrontendSandbox,alpha,@grafana/plugins-platform-backend,false,false,false,true

1 Name State Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
79 dataSourcePageHeader beta @grafana/enterprise-datasources false false false true
80 extraThemes alpha @grafana/user-essentials false false false true
81 lokiPredefinedOperations alpha @grafana/observability-logs false false false true
82 pluginsFrontendSandbox alpha @grafana/plugins-platform-backend false false false true

View File

@ -326,4 +326,8 @@ const (
// FlagLokiPredefinedOperations
// Adds predefined query operations to Loki query editor
FlagLokiPredefinedOperations = "lokiPredefinedOperations"
// FlagPluginsFrontendSandbox
// Enables the plugins frontend sandbox
FlagPluginsFrontendSandbox = "pluginsFrontendSandbox"
)

View File

@ -37,7 +37,12 @@ export function syncGetPanelPlugin(id: string): PanelPlugin | undefined {
}
function getPanelPlugin(meta: PanelPluginMeta): Promise<PanelPlugin> {
return importPluginModule(meta.module, meta.info?.version)
return importPluginModule({
path: meta.module,
version: meta.info?.version,
isAngular: meta.angularDetected,
pluginId: meta.id,
})
.then((pluginExports) => {
if (pluginExports.plugin) {
return pluginExports.plugin as PanelPlugin;

View File

@ -22,7 +22,12 @@ async function preload(config: AppPluginConfig): Promise<PluginPreloadResult> {
const { path, version, id: pluginId } = config;
try {
startMeasure(`frontend_plugin_preload_${pluginId}`);
const { plugin } = await pluginLoader.importPluginModule(path, version);
const { plugin } = await pluginLoader.importPluginModule({
path,
version,
isAngular: config.angularDetected,
pluginId,
});
const { extensionConfigs = [] } = plugin;
return { pluginId, extensionConfigs };
} catch (error) {

View File

@ -33,6 +33,8 @@ import * as ticks from 'app/core/utils/ticks';
import { GenericDataSourcePlugin } from '../datasources/types';
import builtInPlugins from './built_in_plugins';
import { sandboxPluginDependencies } from './sandbox/plugin_dependencies';
import { importPluginModuleInSandbox } from './sandbox/sandbox_plugin_loader';
import { locateFromCDN, translateForCDN } from './systemjsPlugins/pluginCDN';
import { fetchCSS, locateCSS } from './systemjsPlugins/pluginCSS';
import { locateWithCache, registerPluginInCache } from './systemjsPlugins/pluginCacheBuster';
@ -88,6 +90,11 @@ export function exposeToPlugin(name: string, component: any) {
grafanaRuntime.SystemJS.registerDynamic(name, [], true, (require: any, exports: any, module: { exports: any }) => {
module.exports = component;
});
// exposes this dependency to sandboxed plugins too.
// the following sandboxPluginDependencies don't depend or interact
// with SystemJS in any way.
sandboxPluginDependencies.set(name, component);
}
exposeToPlugin('@grafana/data', grafanaData);
@ -186,7 +193,17 @@ for (const flotDep of flotDeps) {
exposeToPlugin(flotDep, { fakeDep: 1 });
}
export async function importPluginModule(path: string, version?: string): Promise<any> {
export async function importPluginModule({
path,
version,
isAngular,
pluginId,
}: {
path: string;
pluginId: string;
version?: string;
isAngular?: boolean;
}): Promise<any> {
if (version) {
registerPluginInCache({ path, version });
}
@ -200,11 +217,26 @@ export async function importPluginModule(path: string, version?: string): Promis
return builtIn;
}
}
// the sandboxing environment code cannot work in nodejs and requires a real browser
if (isFrontendSandboxSupported(isAngular)) {
return importPluginModuleInSandbox({ pluginId });
}
return grafanaRuntime.SystemJS.import(path);
}
function isFrontendSandboxSupported(isAngular?: boolean): boolean {
return !isAngular && Boolean(config.featureToggles.pluginsFrontendSandbox) && process.env.NODE_ENV !== 'test';
}
export function importDataSourcePlugin(meta: grafanaData.DataSourcePluginMeta): Promise<GenericDataSourcePlugin> {
return importPluginModule(meta.module, meta.info?.version).then((pluginExports) => {
return importPluginModule({
path: meta.module,
version: meta.info?.version,
isAngular: meta.angularDetected,
pluginId: meta.id,
}).then((pluginExports) => {
if (pluginExports.plugin) {
const dsPlugin = pluginExports.plugin as GenericDataSourcePlugin;
dsPlugin.meta = meta;
@ -227,7 +259,12 @@ export function importDataSourcePlugin(meta: grafanaData.DataSourcePluginMeta):
}
export function importAppPlugin(meta: grafanaData.PluginMeta): Promise<grafanaData.AppPlugin> {
return importPluginModule(meta.module, meta.info?.version).then((pluginExports) => {
return importPluginModule({
path: meta.module,
version: meta.info?.version,
isAngular: meta.angularDetected,
pluginId: meta.id,
}).then((pluginExports) => {
const plugin = pluginExports.plugin ? (pluginExports.plugin as grafanaData.AppPlugin) : new grafanaData.AppPlugin();
plugin.init(meta);
plugin.meta = meta;

View File

@ -0,0 +1,73 @@
type DistortionMap = Map<unknown, unknown>;
const generalDistortionMap: DistortionMap = new Map();
function failToSet() {
throw new Error('Plugins are not allowed to set sandboxed properties');
}
// sets distortion to protect iframe elements
function distortIframeAttributes(distortions: DistortionMap) {
const iframeHtmlForbiddenProperties = ['contentDocument', 'contentWindow', 'src', 'srcdoc', 'srcObject', 'srcset'];
for (const property of iframeHtmlForbiddenProperties) {
const descriptor = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, property);
if (descriptor) {
function fail() {
throw new Error('iframe.' + property + ' is not allowed in sandboxed plugins');
}
if (descriptor.value) {
distortions.set(descriptor.value, fail);
}
if (descriptor.set) {
distortions.set(descriptor.set, fail);
}
if (descriptor.get) {
distortions.set(descriptor.get, fail);
}
}
}
}
function distortConsole(distortions: DistortionMap) {
// distorts window.console to prefix it
const descriptor = Object.getOwnPropertyDescriptor(window, 'console');
if (descriptor?.value) {
function sandboxLog(...args: unknown[]) {
console.log(`[plugin]`, ...args);
}
const sandboxConsole = {
log: sandboxLog,
warn: sandboxLog,
error: sandboxLog,
info: sandboxLog,
debug: sandboxLog,
};
distortions.set(descriptor.value, sandboxConsole);
}
if (descriptor?.set) {
distortions.set(descriptor.set, failToSet);
}
}
function distortAlert(distortions: DistortionMap) {
const descriptor = Object.getOwnPropertyDescriptor(window, 'alert');
if (descriptor?.value) {
function sandboxAlert(...args: unknown[]) {
console.log(`[plugin]`, ...args);
}
distortions.set(descriptor.value, sandboxAlert);
}
if (descriptor?.set) {
distortions.set(descriptor.set, failToSet);
}
}
export function getGeneralSandboxDistortionMap() {
if (generalDistortionMap.size === 0) {
distortIframeAttributes(generalDistortionMap);
distortConsole(generalDistortionMap);
distortAlert(generalDistortionMap);
}
return generalDistortionMap;
}

View File

@ -0,0 +1,5 @@
/**
* Map with all dependencies that are exposed to plugins sandbox
* e.g.: @grafana/ui, @grafana/data, etc...
*/
export const sandboxPluginDependencies = new Map<string, unknown>([]);

View File

@ -0,0 +1,175 @@
import createVirtualEnvironment from '@locker/near-membrane-dom';
import { ProxyTarget } from '@locker/near-membrane-shared';
import { GrafanaPlugin, PluginMeta } from '@grafana/data';
import { getPluginSettings } from '../pluginSettings';
import { getGeneralSandboxDistortionMap } from './distortion_map';
import { sandboxPluginDependencies } from './plugin_dependencies';
type CompartmentDependencyModule = unknown;
type PluginFactoryFunction = (...args: CompartmentDependencyModule[]) => {
plugin: GrafanaPlugin;
};
const pluginImportCache = new Map<string, Promise<unknown>>();
export async function importPluginModuleInSandbox({ pluginId }: { pluginId: string }): Promise<unknown> {
try {
const pluginMeta = await getPluginSettings(pluginId);
if (!pluginImportCache.has(pluginId)) {
pluginImportCache.set(pluginId, doImportPluginModuleInSandbox(pluginMeta));
}
return pluginImportCache.get(pluginId);
} catch (e) {
throw new Error(`Could not import plugin ${pluginId} inside sandbox: ` + e);
}
}
async function doImportPluginModuleInSandbox(meta: PluginMeta): Promise<unknown> {
const generalDistortionMap = getGeneralSandboxDistortionMap();
function distortionCallback(v: ProxyTarget): ProxyTarget {
return generalDistortionMap.get(v) ?? v;
}
return new Promise(async (resolve, reject) => {
// each plugin has its own sandbox
const sandboxEnvironment = createVirtualEnvironment(window, {
// distortions are interceptors to modify the behavior of objects when
// the code inside the sandbox tries to access them
distortionCallback,
// endowments are custom variables we make available to plugins in their window object
endowments: Object.getOwnPropertyDescriptors({
// Plugins builds use the AMD module system. Their code consists
// of a single function call to `define()` that internally contains all the plugin code.
// This is that `define` function the plugin will call.
// More info about how AMD works https://github.com/amdjs/amdjs-api/blob/master/AMD.md
// Plugins code normally use the "anonymous module" signature: define(depencies, factoryFunction)
define(
idOrDependencies: string | string[],
maybeDependencies: string[] | PluginFactoryFunction,
maybeFactory?: PluginFactoryFunction
): void {
let dependencies: string[];
let factory: PluginFactoryFunction;
if (Array.isArray(idOrDependencies)) {
dependencies = idOrDependencies;
factory = maybeDependencies as PluginFactoryFunction;
} else {
dependencies = maybeDependencies as string[];
factory = maybeFactory!;
}
try {
const resolvedDeps = resolvePluginDependencies(dependencies);
// execute the plugin's code
const pluginExports: { plugin: GrafanaPlugin } = factory.apply(null, resolvedDeps);
// only after the plugin has been executed
// we can return the plugin exports.
// This is what grafana effectively gets.
resolve(pluginExports);
} catch (e) {
reject(new Error(`Could not execute plugin ${meta.id}: ` + e));
}
},
}),
// This improves the error message output for plugins
// because errors thrown inside of the sandbox have a stack
// trace that is difficult to read due to all the sandboxing
// layers.
instrumentation: {
// near-membrane concept of "activity" is something that happens inside
// the plugin instrumentation
startActivity() {
return {
stop: () => {},
error: getActivityErrorHandler(meta.id),
};
},
log: () => {},
error: () => {},
},
});
// fetch and evalute the plugin code inside the sandbox
try {
let pluginCode = await getPluginCode(meta.module);
pluginCode = patchPluginSourceMap(meta, pluginCode);
// runs the code inside the sandbox environment
// this evaluate will eventually run the `define` function inside
// of endowments.
sandboxEnvironment.evaluate(pluginCode);
} catch (e) {
reject(new Error(`Could not execute plugin ${meta.id}: ` + e));
}
});
}
async function getPluginCode(modulePath: string) {
const response = await fetch('public/' + modulePath + '.js');
return await response.text();
}
function getActivityErrorHandler(pluginId: string) {
return async function error(proxyError?: Error & { sandboxError?: boolean }) {
if (!proxyError) {
return;
}
// flag this error as a sandbox error
proxyError.sandboxError = true;
// create a new error to unwrap it from the proxy
const newError = new Error(proxyError.message.toString());
newError.name = proxyError.name.toString();
newError.stack = proxyError.stack || '';
// If you are seeing this is because
// the plugin is throwing an error
// and it is not being caught by the plugin code
// This is a sandbox wrapper error.
// and not the real error
console.log(`[sandbox] Error from plugin ${pluginId}`);
console.error(newError);
};
}
/**
* 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[] = [];
for (const dep of deps) {
const resolvedDep = sandboxPluginDependencies.get(dep);
if (!resolvedDep) {
throw new Error(`[sandbox] Could not resolve dependency ${dep}`);
}
resolvedDeps.push(resolvedDep);
}
return resolvedDeps;
}

View File

@ -0,0 +1,5 @@
export default function () {
return {
evaluate: () => {},
};
}

View File

@ -5195,6 +5195,42 @@ __metadata:
languageName: node
linkType: hard
"@locker/near-membrane-base@npm:0.12.14":
version: 0.12.14
resolution: "@locker/near-membrane-base@npm:0.12.14"
dependencies:
"@locker/near-membrane-shared": 0.12.14
checksum: f6878bbb59bf5241632e610c521be9138cc039d7983d2f9b3aeb670483446364a1b92d407f93fc1ae2834093d033f4223752cb9fb012a48f4b2eb985974a425c
languageName: node
linkType: hard
"@locker/near-membrane-dom@npm:^0.12.14":
version: 0.12.14
resolution: "@locker/near-membrane-dom@npm:0.12.14"
dependencies:
"@locker/near-membrane-base": 0.12.14
"@locker/near-membrane-shared": 0.12.14
"@locker/near-membrane-shared-dom": 0.12.14
checksum: fa8178feaa691fcd5c18405387b1296945da7b13ee8fade2efcbd1bbcd57dd17e2015ca70fe41807ae0c2892aa542286a5c07ac4c37c59032333642b7ae4628b
languageName: node
linkType: hard
"@locker/near-membrane-shared-dom@npm:0.12.14":
version: 0.12.14
resolution: "@locker/near-membrane-shared-dom@npm:0.12.14"
dependencies:
"@locker/near-membrane-shared": 0.12.14
checksum: 7e3e6352b0f4aa3306e1b1f49f11b5ddc0c9820dd84d8317b68545d949fa583b2f7b3ab0c56446769e7d993431d1baf3245fd27ff255ee3e1c2c95d3e5c1876c
languageName: node
linkType: hard
"@locker/near-membrane-shared@npm:0.12.14, @locker/near-membrane-shared@npm:^0.12.14":
version: 0.12.14
resolution: "@locker/near-membrane-shared@npm:0.12.14"
checksum: f5e75ae422b5369ba5323a72e7cdb979ccc2178c7202a6e85b7e9adaf9971a367de413503cd1e743672798afec1f53836bdbe849acadfb640e41d93ee0125194
languageName: node
linkType: hard
"@mapbox/jsonlint-lines-primitives@npm:~2.0.2":
version: 2.0.2
resolution: "@mapbox/jsonlint-lines-primitives@npm:2.0.2"
@ -18162,6 +18198,8 @@ __metadata:
"@lezer/common": 1.0.2
"@lezer/highlight": 1.1.3
"@lezer/lr": 1.3.3
"@locker/near-membrane-dom": ^0.12.14
"@locker/near-membrane-shared": ^0.12.14
"@opentelemetry/api": 1.4.0
"@opentelemetry/exporter-collector": 0.25.0
"@opentelemetry/semantic-conventions": 1.9.1