Skip to content

Commit 7823e55

Browse files
🤖 backported "sandbox custom react components" (metabase#75383)
sandbox custom react components (metabase#73941) * WIP: sandbox custom react components * improve typing of widget mount * simplify widget mount module * refactor widget mount * refactor widget mount # Conflicts: # enterprise/frontend/src/metabase-enterprise/custom_viz/widget-mount.unit.spec.ts * refactor viz def settings sanitizer * improve types * cleanup naming * adjust readme and docs * adjust docs * use single generic mount to render viz and settings * improve types * simplify render in defineConfig * extract use-plugin-mount * store test custom viz plugin sources * fix missing locale in custom viz fixture * check string setting widgets with allowlist * add custom viz security custom settings widget security test case * simplify widget mount stamping to plain property * use isObject helper, skip non-object values * add comment explaining rendering flow in usePluginMount * add performMount and props to explicit deps * remove obsolete archive with custom plugin * update test fixture of calendar heatmap * add react settings widget sandboxing e2e test * bump sdk version * revert .only test annotation * improve test name Co-authored-by: Jakub Chodorowicz <jakub.chodorowicz@metabase.com>
1 parent c7eac26 commit 7823e55

23 files changed

Lines changed: 607 additions & 97 deletions

File tree

e2e/support/helpers/e2e-custom-viz-helpers.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,20 @@ export const CUSTOM_VIZ_FIXTURE_TGZ_3_SECURITY =
1010
Cypress.config("projectRoot") +
1111
"/e2e/support/assets/example_custom_viz_plugin_3_security.tgz";
1212

13+
export const CUSTOM_VIZ_FIXTURE_TGZ_4_SECURITY_COMPONENT =
14+
Cypress.config("projectRoot") +
15+
"/e2e/support/assets/example_custom_viz_plugin_4_security_component.tgz";
16+
1317
// Identifier comes from the manifest's `name` field in the packaged bundle.
1418
export const CUSTOM_VIZ_IDENTIFIER = "demo-viz";
1519

1620
export const CUSTOM_VIZ_IDENTIFIER_2 = "demo-viz-2";
1721

1822
export const CUSTOM_VIZ_IDENTIFIER_3_SECURITY = "demo-viz-security";
1923

24+
export const CUSTOM_VIZ_IDENTIFIER_4_SECURITY_COMPONENT =
25+
"demo-viz-security-component";
26+
2027
// Frontend display type: "custom:{identifier}"
2128
export const CUSTOM_VIZ_DISPLAY = `custom:${CUSTOM_VIZ_IDENTIFIER}` as const;
2229

e2e/test/scenarios/visualizations-charts/custom-viz.cy.spec.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2560,4 +2560,42 @@ describe("sandbox", () => {
25602560
),
25612561
);
25622562
});
2563+
2564+
it("sandboxes React component setting widgets", () => {
2565+
H.addCustomVizPlugin(H.CUSTOM_VIZ_FIXTURE_TGZ_4_SECURITY_COMPONENT);
2566+
2567+
H.createQuestion(
2568+
{
2569+
name: "Custom Viz Component Widget Security Test",
2570+
query: {
2571+
"source-table": SAMPLE_DB_TABLES.STATIC_ORDERS_ID,
2572+
aggregation: [["count"]],
2573+
},
2574+
display: "table",
2575+
},
2576+
{ wrapId: true, idAlias: "questionId" },
2577+
);
2578+
2579+
H.visitQuestion("@questionId", {
2580+
onBeforeLoad(win) {
2581+
cy.spy(win.console, "error").as("consoleError");
2582+
},
2583+
});
2584+
2585+
cy.findByTestId("viz-type-button").click();
2586+
cy.findByTestId("custom-viz-plugins-toggle").click();
2587+
cy.findByTestId(
2588+
`${H.CUSTOM_VIZ_IDENTIFIER_4_SECURITY_COMPONENT}-button`,
2589+
).click();
2590+
cy.findByTestId("viz-type-button").click();
2591+
2592+
cy.log("open viz settings to mount the custom component widget");
2593+
cy.findByTestId("viz-settings-button").click();
2594+
2595+
cy.log("the sandbox blocks the component's forbidden <input> element");
2596+
cy.get("@consoleError").should(
2597+
"have.been.calledWithMatch",
2598+
/render failed: \[plugin \d+\] blocked createElement: input/,
2599+
);
2600+
});
25632601
});

enterprise/frontend/src/custom-viz/fixtures/build-example-custom-viz.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ const visualizations = [
3535
manifest: resolve(PLUGIN_ROOT, "manifests/demo-viz-security.json"),
3636
out: resolve(E2E_ASSETS, "example_custom_viz_plugin_3_security.tgz"),
3737
},
38+
{
39+
index: resolve(PLUGIN_ROOT, "src/index-widget-security-component.tsx"),
40+
manifest: resolve(PLUGIN_ROOT, "manifests/demo-viz-security-component.json"),
41+
out: resolve(
42+
E2E_ASSETS,
43+
"example_custom_viz_plugin_4_security_component.tgz",
44+
),
45+
},
3846
];
3947

4048
export async function buildCustomVizFixtures() {
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"name": "demo-viz-security-component",
3+
"icon": "icon.svg",
4+
"metabase": {
5+
"version": ">=0.57.0"
6+
}
7+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import React from "react";
2+
3+
import { defineConfig } from "../../../src/index";
4+
import type { CreateCustomVisualization } from "../../../src/types/viz";
5+
6+
type Settings = {
7+
forbiddenWidget?: string | null;
8+
};
9+
10+
function ForbiddenSettingWidget() {
11+
return (
12+
<form>
13+
<input />
14+
</form>
15+
);
16+
}
17+
18+
const createVisualization: CreateCustomVisualization<Settings> = ({
19+
defineSetting,
20+
}) => {
21+
return defineConfig<Settings>({
22+
id: "example_custom_viz_plugin",
23+
getName: () => "example_custom_viz_plugin",
24+
minSize: { width: 2, height: 2 },
25+
checkRenderable(series) {
26+
if (series.length !== 1) {
27+
throw new Error("Only 1 series is supported");
28+
}
29+
},
30+
settings: {
31+
forbiddenWidget: defineSetting({
32+
id: "forbiddenWidget",
33+
title: "Forbidden widget",
34+
widget: ForbiddenSettingWidget,
35+
getDefault() {
36+
return null;
37+
},
38+
}),
39+
},
40+
VisualizationComponent: () => <h1>Custom viz rendered successfully</h1>,
41+
});
42+
};
43+
44+
export default createVisualization;

enterprise/frontend/src/custom-viz/fixtures/example_custom_viz_plugin/src/index-widget-security.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,20 @@ import type { CreateCustomVisualization } from "../../../src/types/viz";
33
import { Visualization } from "./Visualization";
44
import { Settings } from "./types";
55

6+
// A custom React component used as a setting widget. It renders a forbidden
7+
// element (`<form>`) — rendering it makes React call
8+
// `document.createElement("form")`. When the widget is a component, the host
9+
// wraps it into a sandboxed mount, so the near-membrane sandbox must block the
10+
// element the same way it does for the visualization component itself — the
11+
// SDK's PluginErrorBoundary then reports the failure.
12+
function ForbiddenSettingWidget() {
13+
return (
14+
<form>
15+
<input />
16+
</form>
17+
);
18+
}
19+
620
const createVisualization: CreateCustomVisualization<Settings> = ({
721
defineSetting,
822
locale,
@@ -35,6 +49,14 @@ const createVisualization: CreateCustomVisualization<Settings> = ({
3549
};
3650
},
3751
}),
52+
customWidget: defineSetting({
53+
id: "customWidget",
54+
title: "Custom widget",
55+
widget: ForbiddenSettingWidget,
56+
getDefault() {
57+
return null;
58+
},
59+
}),
3860
},
3961
VisualizationComponent: (props) => (
4062
<Visualization {...props} locale={locale} />
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export type Settings = {
22
threshold?: number;
3+
customWidget?: string | null;
34
};

enterprise/frontend/src/custom-viz/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@metabase/custom-viz",
3-
"version": "1.0.3",
3+
"version": "1.0.4",
44
"description": "Creating custom visualizations for Metabase",
55
"type": "module",
66
"bin": {

enterprise/frontend/src/custom-viz/src/define-config.tsx

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export type CustomVisualizationOpts<TSettings extends Record<string, unknown>> =
1414
};
1515

1616
class PluginErrorBoundary extends Component<
17-
{ children: ReactNode },
17+
{ children: ReactNode; label: string },
1818
{ error: Error | null }
1919
> {
2020
state: { error: Error | null } = { error: null };
@@ -25,7 +25,9 @@ class PluginErrorBoundary extends Component<
2525

2626
componentDidCatch(error: Error) {
2727
const { message, stack } = error;
28-
console.error(`[plugin] visualization render failed: ${message}\n${stack}`);
28+
console.error(
29+
`[plugin] ${this.props.label} render failed: ${message}\n${stack}`,
30+
);
2931
}
3032

3133
render() {
@@ -39,26 +41,26 @@ class PluginErrorBoundary extends Component<
3941
export function defineConfig<TSettings extends Record<string, unknown>>(
4042
opts: CustomVisualizationOpts<TSettings>,
4143
): CustomVisualization<TSettings> {
42-
const { VisualizationComponent, ...rest } = opts;
43-
4444
return {
45-
...rest,
46-
VisualizationComponent,
47-
mount(
45+
...opts,
46+
mount<P extends object>(
47+
Component: ComponentType<P>,
4848
container: Element,
49-
initial: CustomVisualizationProps<TSettings>,
50-
): CustomVisualizationMountHandle<CustomVisualizationProps<TSettings>> {
49+
initialProps: P,
50+
): CustomVisualizationMountHandle<P> {
5151
const root = createRoot(container);
5252

53-
const render = (props: CustomVisualizationProps<TSettings>) => {
53+
const render = (props: P) => {
5454
root.render(
55-
<PluginErrorBoundary>
56-
<VisualizationComponent {...props} />
55+
<PluginErrorBoundary
56+
label={Component.displayName ?? Component.name ?? "plugin"}
57+
>
58+
<Component {...props} />
5759
</PluginErrorBoundary>,
5860
);
5961
};
6062

61-
render(initial);
63+
render(initialProps);
6264

6365
return {
6466
update: render,

enterprise/frontend/src/custom-viz/src/templates/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ settings: {
176176
| `group` | Sub-heading within a section for grouping related settings. |
177177
| `index` | Display order within a group. |
178178
| `inline` | When `true`, renders the widget on the same line as `title` (useful for `"toggle"`). |
179-
| `widget` | Built-in widget name (see below). |
179+
| `widget` | Built-in widget name or a custom React component. |
180180
| `getDefault(series, settings)` | Computes the default value when none is stored. |
181181
| `getValue(series, settings)` | Always-computed value — overrides stored value on every render. |
182182
| `getProps(series, settings)` | Returns widget-specific props. |
@@ -201,6 +201,8 @@ settings: {
201201
| `"field"` | `{ columns, options: { name, value }[], showColumnSetting? }` | Single column picker |
202202
| `"fields"` | `{ columns, options: { name, value }[], addAnother?, showColumnSetting? }` | Multi-column picker |
203203

204+
You can also pass a **custom React component** as `widget`. Its props (minus the base props injected by Metabase) are returned by `getProps`.
205+
204206
---
205207

206208
## Using Images

0 commit comments

Comments
 (0)