Gdgt 2495 workaround unsafe eval near membrane requirement (#74717)

This commit is contained in:
Jakub Chodorowicz
2026-05-27 15:00:50 +02:00
committed by GitHub
parent 3d893c1d75
commit b94b50a122
10 changed files with 266 additions and 24 deletions

View File

@@ -33,7 +33,7 @@
"@juggle/resize-observer": "^3.4.0",
"@lezer/highlight": "^1.2.1",
"@lezer/lr": "^1.4.2",
"@locker/near-membrane-dom": "^0.15.0",
"@locker/near-membrane-dom": "^0.16.0",
"@loki/browser": "0.35.0",
"@mantine/core": "8.3.18",
"@mantine/dates": "8.3.18",
@@ -1134,13 +1134,13 @@
"@lezer/python": ["@lezer/python@1.1.15", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-aVQ43m2zk4FZYedCqL0KHPEUsqZOrmAvRhkhHlVPnDD1HODDyyQv5BRIuod4DadkgBEZd53vQOtXTonNbEgjrQ=="],
"@locker/near-membrane-base": ["@locker/near-membrane-base@0.15.0", "", { "dependencies": { "@locker/near-membrane-shared": "0.15.0" } }, "sha512-ui7JZF4lG0TosGSl1ECGd4w0pwE9JXFw+JB0YYFA61qqw0ZmPk6yacPb6mawGqg1boa/yASZ1JIC5YRfk+jMCw=="],
"@locker/near-membrane-base": ["@locker/near-membrane-base@0.16.0", "", { "dependencies": { "@locker/near-membrane-shared": "0.16.0" } }, "sha512-Ds6FQb3UMdA2L4zoGBtFwux6xD3Vi/4u6tgI+nCaAhCg0LcA138llFp5HasRpRoRkNu4CGdQu4nbC5/7ARybZQ=="],
"@locker/near-membrane-dom": ["@locker/near-membrane-dom@0.15.0", "", { "dependencies": { "@locker/near-membrane-base": "0.15.0", "@locker/near-membrane-shared": "0.15.0", "@locker/near-membrane-shared-dom": "0.15.0" } }, "sha512-sOo9rE7smymkBust2FzBhUAcbKksSTl9q/amroFE4P1Zlof6moTHCvVh3X/wnP4o7Md+yPWOEhNyrAA56kMIsg=="],
"@locker/near-membrane-dom": ["@locker/near-membrane-dom@0.16.0", "", { "dependencies": { "@locker/near-membrane-base": "0.16.0", "@locker/near-membrane-shared": "0.16.0", "@locker/near-membrane-shared-dom": "0.16.0" } }, "sha512-h1FD6qPWwi9b20Yg5jFy8LWqewuJ5rXvj7dpUnYcY5gUAQdQSwOIhCUz8km/MpUIEVqgPKUyj97VwO6PjYpQBA=="],
"@locker/near-membrane-shared": ["@locker/near-membrane-shared@0.15.0", "", {}, "sha512-d6sJH7GrLmK9+k1ujobMCya7sIXIzAhjOCqRSxp03YGgnfurGR3ymq1R2oqdaGJldYIw3W4rD/Zc0n9q01CHfA=="],
"@locker/near-membrane-shared": ["@locker/near-membrane-shared@0.16.0", "", {}, "sha512-b6B8n7Mgi+QOiuJox0pzbw8oTaZuyVGOc/w2Hxd8yg0C/s7eHbG80U6AtBtkvmpfh6Fug8f6zyUgbbkT93av8g=="],
"@locker/near-membrane-shared-dom": ["@locker/near-membrane-shared-dom@0.15.0", "", { "dependencies": { "@locker/near-membrane-shared": "0.15.0" } }, "sha512-Pe3xDLiWHk0NqRjwE3KSGqEXwiQ7pfZIwX8n/9JAfFeUV6AQPrn5+8X4kFQter3zY4Aj8rGg+C9S8BluFtxafg=="],
"@locker/near-membrane-shared-dom": ["@locker/near-membrane-shared-dom@0.16.0", "", { "dependencies": { "@locker/near-membrane-shared": "0.16.0" } }, "sha512-0QFiJzT0cS5hwh4B0CZk4AkWFkl5IO2j0jWgNaZyC5odtvfNZMPKa5pe9MJod7shfXVSN9WaeLshMadgxki7DA=="],
"@loki/browser": ["@loki/browser@0.35.0", "", { "dependencies": { "@loki/integration-core": "^0.35.0" } }, "sha512-VYSd3bx9dOQ/Wo2C1gdNdE2mcYPleVtGInRcZmCOJ01va14L9VEGzG2zL1dMcmMUuJ2cT3Ri1GR59vEO3vKWvQ=="],

View File

@@ -261,6 +261,35 @@
(finally
(try (.delete tempfile) (catch Exception _))))))
(def ^:private sandbox-host-html
"Minimal HTML doc that the patched `@locker/near-membrane-dom` loads as the iframe document
so plugin code can be `eval`'d under a relaxed, per-iframe CSP."
"<!doctype html><html><head><meta charset=\"utf-8\"></head><body></body></html>")
(def ^:private sandbox-host-csp
"CSP applied ONLY to the sandbox iframe document.
- `'unsafe-eval'` required by near-membrane to evaluate plugin code inside the realm.
- `frame-ancestors 'self'` - so Metabase can embed this document."
(str "default-src 'none'; "
"script-src 'unsafe-eval'; "
"frame-ancestors 'self';"))
(api.macros/defendpoint :get "/sandbox-host" :- :any
"Serve a minimal HTML document used as the iframe `src` for the near-membrane custom-viz
sandbox. The response carries a per-document `Content-Security-Policy` that permits
`'unsafe-eval'` only inside this iframe, so the main Metabase document keeps its strict
nonce-based CSP."
[]
{:status 200
:headers {"Content-Type" "text/html; charset=utf-8"
"Content-Security-Policy" sandbox-host-csp
"X-Frame-Options" "SAMEORIGIN"
"X-Content-Type-Options" "nosniff"
"Cross-Origin-Resource-Policy" "same-origin"
"Referrer-Policy" "no-referrer"
"Cache-Control" "public, max-age=60"}
:body sandbox-host-html})
(api.macros/defendpoint :get "/:id/bundle" :- :any
"Serve the JS bundle for a plugin from the on-disk cache.
Returns application/javascript with ETag and Cache-Control headers.

View File

@@ -442,6 +442,44 @@
(is (contains? identifiers "compat-viz"))
(is (not (contains? identifiers "incompat-viz")))))))))
;;; ------------------------------------------------ Sandbox-host Endpoint ------------------------------------------------
(deftest sandbox-host-endpoint-test
(testing "GET /sandbox-host returns minimal HTML with a per-document CSP"
(mt/with-premium-features #{:custom-viz}
(let [resp (mt/user-http-request-full-response
:rasta :get 200 "ee/custom-viz-plugin/sandbox-host")
headers (:headers resp)]
(testing "returns a tiny HTML body"
(is (string? (:body resp)))
(is (str/includes? (:body resp) "<!doctype html>"))
(is (str/includes? (:body resp) "<body></body>")))
(testing "response is served as HTML"
(is (str/starts-with? (get headers "Content-Type") "text/html")))
(testing "Content-Security-Policy allows unsafe-eval only inside the sandbox doc"
(let [csp (get headers "Content-Security-Policy")]
(is (some? csp))
(is (str/includes? csp "default-src 'none'"))
(is (str/includes? csp "script-src 'unsafe-eval'"))
(is (not (str/includes? csp "sandbox")))
(is (str/includes? csp "frame-ancestors 'self'"))))
(testing "framing is allowed for same-origin (overrides the global X-Frame-Options: DENY)"
(is (= "SAMEORIGIN" (get headers "X-Frame-Options"))))
(testing "hardening headers are present"
(is (= "nosniff" (get headers "X-Content-Type-Options")))
(is (= "no-referrer" (get headers "Referrer-Policy")))
(is (= "same-origin" (get headers "Cross-Origin-Resource-Policy")))
(is (= "public, max-age=60" (get headers "Cache-Control")))))))
(testing "GET /sandbox-host requires the :custom-viz premium feature"
(mt/with-premium-features #{}
(mt/assert-has-premium-feature-error
"Custom Visualizations"
(mt/user-http-request :rasta :get 402 "ee/custom-viz-plugin/sandbox-host"))))
(testing "GET /sandbox-host requires authentication"
(mt/with-premium-features #{:custom-viz}
(is (= "Unauthenticated"
(client/client :get 401 "ee/custom-viz-plugin/sandbox-host"))))))
;;; ------------------------------------------------ Bundle Endpoint ------------------------------------------------
(deftest bundle-endpoint-test

View File

@@ -39,6 +39,7 @@ import { isCustomVizDisplay } from "metabase-types/guards/visualization";
import { trackCustomVizSelected } from "./analytics";
import { applyDefaultVisualizationProps } from "./custom-viz-common";
import { ensureVizApi } from "./custom-viz-globals";
import type { SandboxMode } from "./sandbox";
// Track which plugins have already been loaded to avoid re-execution.
// Maps plugin id → { identifier, hash } so we can detect when a re-uploaded
@@ -105,7 +106,10 @@ function useCustomVizDevReload(
);
setLoading(true);
try {
await loadCustomVizPlugin(plugin, `?t=${Date.now()}`, onInfo);
await loadCustomVizPlugin(plugin, {
cacheBustSuffix: `?t=${Date.now()}`,
onInfo,
});
} finally {
setLoading(false);
}
@@ -161,7 +165,7 @@ export function useAutoLoadCustomVizPlugin(display: string | undefined): {
loadingRef.current = identifier;
setLoading(true);
try {
await loadCustomVizPlugin(pluginToLoad, undefined, onInfo);
await loadCustomVizPlugin(pluginToLoad, { onInfo });
} finally {
loadingRef.current = null;
setLoading(false);
@@ -246,6 +250,12 @@ export function useAutoLoadCustomVizPlugin(display: string | undefined): {
return { loading: needsCustomViz && !isReady };
}
export type LoadCustomVizPluginOptions = {
cacheBustSuffix?: string;
onInfo?: (message: string) => void;
sandboxMode?: SandboxMode;
};
/**
* Dynamically load a custom viz plugin bundle, call its factory,
* decompose the returned definition, and register it as a Metabase
@@ -253,9 +263,9 @@ export function useAutoLoadCustomVizPlugin(display: string | undefined): {
*/
export async function loadCustomVizPlugin(
plugin: CustomVizPluginRuntime,
cacheBustSuffix?: string,
onInfo?: (message: string) => void,
options: LoadCustomVizPluginOptions = {},
): Promise<string | null> {
const { cacheBustSuffix, onInfo, sandboxMode = "hosted" } = options;
const existing = loadedPlugins.get(plugin.id);
const currentHash = plugin.bundle_hash ?? null;
if (
@@ -291,7 +301,7 @@ export async function loadCustomVizPlugin(
// up in the static-viz bundle, which is evaluated by GraalVM and has no
// DOM constructors.
const { createPluginSandbox } = await import("./sandbox");
const sandbox = createPluginSandbox(plugin.id);
const sandbox = await createPluginSandbox(plugin.id, sandboxMode);
const factory = sandbox.evaluate(text);
if (typeof factory !== "function") {

View File

@@ -1,18 +1,34 @@
import createVirtualEnvironment from "@locker/near-membrane-dom";
import { getSubpathSafeUrl } from "metabase/urls";
import type { CustomVizPluginId } from "metabase-types/api";
import { makeDistortionCallback } from "./distortions";
export type SandboxMode = "hosted" | "blank";
// Needed for React style declarations to be applied correctly.
function isLiveTarget(target: object): boolean {
return target instanceof CSSStyleDeclaration;
}
export function createPluginSandbox(pluginId: CustomVizPluginId) {
// Same-origin endpoint that serves a tiny HTML document with a permissive,
// per-document CSP (allowing `'unsafe-eval'`).
const SANDBOX_HOST_URL = "/api/ee/custom-viz-plugin/sandbox-host";
export async function createPluginSandbox(
pluginId: CustomVizPluginId,
mode: SandboxMode = "hosted",
) {
let capturedFactory: unknown;
const env = createVirtualEnvironment(window, {
// "blank" skips the sandbox-host endpoint and lets the library fall back to
// about:blank. Used in Storybook where no backend serves the endpoint.
const iframeSrc =
mode === "hosted" ? getSubpathSafeUrl(SANDBOX_HOST_URL) : undefined;
const env = await createVirtualEnvironment(window, {
...(iframeSrc ? { iframeSrc } : {}),
distortionCallback: makeDistortionCallback(pluginId),
liveTargetCallback: isLiveTarget,
endowments: Object.getOwnPropertyDescriptors({

View File

@@ -33,8 +33,10 @@ const getDefaultPluginCustomViz = () => ({
},
loadCustomVizPlugin: async (
_plugin: CustomVizPluginRuntime,
_cacheBustSuffix?: string,
_onInfo?: (message: string) => void,
_options?: {
cacheBustSuffix?: string;
onInfo?: (message: string) => void;
},
) => null as string | null,
getPluginAssetUrl: (_pluginId: number, _assetPath: string | null) =>
undefined as string | undefined,

View File

@@ -63,7 +63,7 @@ export const ChartTypeSidebar = ({
let cancelled = false;
Promise.all(
customVizPlugins.map((plugin) =>
PLUGIN_CUSTOM_VIZ.loadCustomVizPlugin(plugin, undefined, onInfo),
PLUGIN_CUSTOM_VIZ.loadCustomVizPlugin(plugin, { onInfo }),
),
).then(() => {
if (!cancelled) {

View File

@@ -57,14 +57,21 @@ export function useHeatmapPlugin() {
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
loadCustomVizPlugin({
id: 1,
bundle_hash: "dev",
bundle_url: `${PLUGIN_BASE_URL}/index.js`,
display_name: "Calendar Heatmap",
identifier: PLUGIN_IDENTIFIER,
icon: `${PLUGIN_BASE_URL}/assets/calendar.svg`,
}).then(() => {
loadCustomVizPlugin(
{
id: 1,
bundle_hash: "dev",
bundle_url: `${PLUGIN_BASE_URL}/index.js`,
display_name: "Calendar Heatmap",
identifier: PLUGIN_IDENTIFIER,
icon: `${PLUGIN_BASE_URL}/assets/calendar.svg`,
},
{
// Storybook has no backend for the sandbox-host endpoint and no strict
// parent CSP, so use about:blank instead of the iframe-src sandbox.
sandboxMode: "blank",
},
).then(() => {
if (!cancelled) {
setReady(true);
}

View File

@@ -42,7 +42,7 @@
"@juggle/resize-observer": "^3.4.0",
"@lezer/highlight": "^1.2.1",
"@lezer/lr": "^1.4.2",
"@locker/near-membrane-dom": "^0.15.0",
"@locker/near-membrane-dom": "^0.16.0",
"@loki/browser": "0.35.0",
"@mantine/core": "8.3.18",
"@mantine/dates": "8.3.18",

View File

@@ -0,0 +1,140 @@
diff --git a/node_modules/@locker/near-membrane-dom/dist/index.mjs.js b/node_modules/@locker/near-membrane-dom/dist/index.mjs.js
--- a/node_modules/@locker/near-membrane-dom/dist/index.mjs.js
+++ b/node_modules/@locker/near-membrane-dom/dist/index.mjs.js
@@ -107,7 +107,7 @@
const IFRAME_SANDBOX_ATTRIBUTE_VALUE = 'allow-same-origin allow-scripts';
const revoked = toSafeWeakSet(new WeakSetCtor());
const blueCreateHooksCallbackCache = toSafeWeakMap(new WeakMapCtor());
-function createDetachableIframe(doc) {
+function createDetachableIframe(doc, iframeSrc) {
var _ReflectApply;
const iframe = ReflectApply(DocumentProtoCreateElement, doc, ['iframe']);
// It is impossible to test whether the NodeProtoLastChildGetter branch is
@@ -116,6 +116,13 @@
const style = ReflectApply(HTMLElementProtoStyleGetter, iframe, []);
style.display = 'none';
ReflectApply(ElementProtoSetAttribute, iframe, ['sandbox', IFRAME_SANDBOX_ATTRIBUTE_VALUE]);
+ // Metabase patch: when `iframeSrc` is provided, load the iframe from that URL so
+ // its document carries the per-document CSP returned by the server (allowing
+ // `'unsafe-eval'` only inside the sandbox), instead of inheriting the parent's
+ // strict CSP via about:blank.
+ if (iframeSrc) {
+ ReflectApply(ElementProtoSetAttribute, iframe, ['src', iframeSrc]);
+ }
ReflectApply(NodeProtoAppendChild, parent, [iframe]);
return iframe;
}
@@ -132,6 +139,9 @@
defaultPolicy,
endowments,
globalObjectShape,
+ // Metabase patch: when set, the iframe loads this URL instead of about:blank
+ // so the sandbox document gets its own per-document CSP.
+ iframeSrc,
instrumentation,
keepAlive = true,
liveTargetCallback,
@@ -142,7 +152,8 @@
} = ObjectAssign({
__proto__: null
}, providedOptions);
- const iframe = createDetachableIframe(blueRefs.document);
+ const iframe = createDetachableIframe(blueRefs.document, iframeSrc);
+ const finishSetup = () => {
const redWindow = ReflectApply(HTMLIFrameElementProtoContentWindowGetter, iframe, []);
const shouldUseDefaultGlobalOwnKeys = typeof globalObjectShape !== 'object' || globalObjectShape === null;
const defaultGlobalOwnKeys = filterWindowKeys(getFilteredGlobalOwnKeys(redWindow, maxPerfMode));
@@ -218,8 +229,14 @@
// Revoke the proxies of the redDocument and redWindow to prevent access.
revoked.add(redDocument);
revoked.add(redWindow);
- ReflectApply(DocumentProtoOpen, redDocument, []);
- ReflectApply(DocumentProtoClose, redDocument, []);
+ // Metabase patch: when an `iframeSrc` loaded a real document, skip the
+ // open/close dance. Those calls would tear down the loaded document and
+ // replace it with one whose CSP is inherited from the parent (about:blank
+ // semantics), defeating the whole point of per-iframe CSP.
+ if (!iframeSrc) {
+ ReflectApply(DocumentProtoOpen, redDocument, []);
+ ReflectApply(DocumentProtoClose, redDocument, []);
+ }
} else {
// istanbul ignore next: coverage never runs in old Chromium (<v86)
if (IS_OLD_CHROMIUM_BROWSER) {
@@ -231,6 +248,22 @@
ReflectApply(ElementProtoRemove, iframe, []);
}
return env;
+ };
+ // Metabase patch: when `iframeSrc` is set, wait for the iframe to load before
+ // touching `contentWindow` so we read the realm of the loaded document (with
+ // its server-sent CSP) rather than the initial about:blank one. Existing
+ // callers without `iframeSrc` keep the synchronous return shape.
+ if (iframeSrc) {
+ return new Promise((resolve, reject) => {
+ iframe.addEventListener('load', () => {
+ try { resolve(finishSetup()); } catch (e) { reject(e); }
+ }, { once: true });
+ iframe.addEventListener('error', () => {
+ reject(new Error('Failed to load sandbox iframe at ' + iframeSrc));
+ }, { once: true });
+ });
+ }
+ return finishSetup();
}
function revokedProxyCallback(value) {
return revoked.has(value);
diff --git a/node_modules/@locker/near-membrane-dom/dist/index.mjs.d.ts b/node_modules/@locker/near-membrane-dom/dist/index.mjs.d.ts
--- a/node_modules/@locker/near-membrane-dom/dist/index.mjs.d.ts
+++ b/node_modules/@locker/near-membrane-dom/dist/index.mjs.d.ts
@@ -4,6 +4,15 @@
defaultPolicy?: object;
endowments?: PropertyDescriptorMap;
globalObjectShape?: object;
+ /**
+ * Metabase patch: when set, the sandbox iframe loads this URL instead of
+ * about:blank. The loaded document keeps its server-sent CSP, which lets
+ * the sandbox carry a different (permissive) CSP from the parent page. When
+ * this option is supplied, `createIframeVirtualEnvironment` returns a
+ * Promise<VirtualEnvironment> so callers can await iframe `load` before
+ * accessing the realm.
+ */
+ iframeSrc?: string;
instrumentation?: Instrumentation;
keepAlive?: boolean;
liveTargetCallback?: LiveTargetCallback;
@@ -11,6 +20,7 @@
protectDistortions?: boolean;
signSourceCallback?: SignSourceCallback;
}
+declare function createIframeVirtualEnvironment(globalObject: WindowProxy & typeof globalThis, providedOptions: BrowserEnvironmentOptions & { iframeSrc: string }): Promise<VirtualEnvironment>;
declare function createIframeVirtualEnvironment(globalObject: WindowProxy & typeof globalThis, providedOptions?: BrowserEnvironmentOptions): VirtualEnvironment;
export { createIframeVirtualEnvironment as default, BrowserEnvironmentOptions };
export type { Connector, DistortionCallback, Instrumentation, LiveTargetCallback } from '@locker/near-membrane-base';
diff --git a/node_modules/@locker/near-membrane-dom/dist/index.cjs.d.ts b/node_modules/@locker/near-membrane-dom/dist/index.cjs.d.ts
--- a/node_modules/@locker/near-membrane-dom/dist/index.cjs.d.ts
+++ b/node_modules/@locker/near-membrane-dom/dist/index.cjs.d.ts
@@ -4,6 +4,15 @@
defaultPolicy?: object;
endowments?: PropertyDescriptorMap;
globalObjectShape?: object;
+ /**
+ * Metabase patch: when set, the sandbox iframe loads this URL instead of
+ * about:blank. The loaded document keeps its server-sent CSP, which lets
+ * the sandbox carry a different (permissive) CSP from the parent page. When
+ * this option is supplied, `createIframeVirtualEnvironment` returns a
+ * Promise<VirtualEnvironment> so callers can await iframe `load` before
+ * accessing the realm.
+ */
+ iframeSrc?: string;
instrumentation?: Instrumentation;
keepAlive?: boolean;
liveTargetCallback?: LiveTargetCallback;
@@ -11,6 +20,7 @@
protectDistortions?: boolean;
signSourceCallback?: SignSourceCallback;
}
+declare function createIframeVirtualEnvironment(globalObject: WindowProxy & typeof globalThis, providedOptions: BrowserEnvironmentOptions & { iframeSrc: string }): Promise<VirtualEnvironment>;
declare function createIframeVirtualEnvironment(globalObject: WindowProxy & typeof globalThis, providedOptions?: BrowserEnvironmentOptions): VirtualEnvironment;
export { createIframeVirtualEnvironment as default, BrowserEnvironmentOptions };
export type { Connector, DistortionCallback, Instrumentation, LiveTargetCallback } from '@locker/near-membrane-base';