From b94b50a1224de8436c7f85b9d99651333885cd43 Mon Sep 17 00:00:00 2001 From: Jakub Chodorowicz Date: Wed, 27 May 2026 15:00:50 +0200 Subject: [PATCH] Gdgt 2495 workaround unsafe eval near membrane requirement (#74717) --- bun.lock | 10 +- .../custom_viz_plugin/api.clj | 29 ++++ .../custom_viz_plugin/api_test.clj | 38 +++++ .../custom_viz/custom-viz-plugins.tsx | 20 ++- .../custom_viz/sandbox/index.ts | 20 ++- .../src/metabase/plugins/oss/custom-viz.ts | 6 +- .../ChartTypeSidebar/ChartTypeSidebar.tsx | 2 +- .../calendar-heatmap-fixtures.ts | 23 ++- package.json | 2 +- .../@locker+near-membrane-dom+0.16.0.patch | 140 ++++++++++++++++++ 10 files changed, 266 insertions(+), 24 deletions(-) create mode 100644 patches/@locker+near-membrane-dom+0.16.0.patch diff --git a/bun.lock b/bun.lock index 2cdff9e1148..ef827617f16 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/enterprise/backend/src/metabase_enterprise/custom_viz_plugin/api.clj b/enterprise/backend/src/metabase_enterprise/custom_viz_plugin/api.clj index ea136f621ea..2b75ff29706 100644 --- a/enterprise/backend/src/metabase_enterprise/custom_viz_plugin/api.clj +++ b/enterprise/backend/src/metabase_enterprise/custom_viz_plugin/api.clj @@ -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." + "") + +(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. diff --git a/enterprise/backend/test/metabase_enterprise/custom_viz_plugin/api_test.clj b/enterprise/backend/test/metabase_enterprise/custom_viz_plugin/api_test.clj index 738566a90e4..0e2a5a50746 100644 --- a/enterprise/backend/test/metabase_enterprise/custom_viz_plugin/api_test.clj +++ b/enterprise/backend/test/metabase_enterprise/custom_viz_plugin/api_test.clj @@ -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) "")) + (is (str/includes? (:body resp) ""))) + (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 diff --git a/enterprise/frontend/src/metabase-enterprise/custom_viz/custom-viz-plugins.tsx b/enterprise/frontend/src/metabase-enterprise/custom_viz/custom-viz-plugins.tsx index b0b377f40ef..a899f73b9d3 100644 --- a/enterprise/frontend/src/metabase-enterprise/custom_viz/custom-viz-plugins.tsx +++ b/enterprise/frontend/src/metabase-enterprise/custom_viz/custom-viz-plugins.tsx @@ -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 { + 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") { diff --git a/enterprise/frontend/src/metabase-enterprise/custom_viz/sandbox/index.ts b/enterprise/frontend/src/metabase-enterprise/custom_viz/sandbox/index.ts index 50e9d884718..2e8d2637296 100644 --- a/enterprise/frontend/src/metabase-enterprise/custom_viz/sandbox/index.ts +++ b/enterprise/frontend/src/metabase-enterprise/custom_viz/sandbox/index.ts @@ -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({ diff --git a/frontend/src/metabase/plugins/oss/custom-viz.ts b/frontend/src/metabase/plugins/oss/custom-viz.ts index 558e324f9fa..87cc59851b2 100644 --- a/frontend/src/metabase/plugins/oss/custom-viz.ts +++ b/frontend/src/metabase/plugins/oss/custom-viz.ts @@ -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, diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/ChartTypeSidebar/ChartTypeSidebar.tsx b/frontend/src/metabase/query_builder/components/view/sidebars/ChartTypeSidebar/ChartTypeSidebar.tsx index 764acf3198f..7db9df0ab60 100644 --- a/frontend/src/metabase/query_builder/components/view/sidebars/ChartTypeSidebar/ChartTypeSidebar.tsx +++ b/frontend/src/metabase/query_builder/components/view/sidebars/ChartTypeSidebar/ChartTypeSidebar.tsx @@ -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) { diff --git a/frontend/test/__support__/custom-viz-fixtures/calendar-heatmap-fixtures.ts b/frontend/test/__support__/custom-viz-fixtures/calendar-heatmap-fixtures.ts index 7689b895c63..3c04dda3401 100644 --- a/frontend/test/__support__/custom-viz-fixtures/calendar-heatmap-fixtures.ts +++ b/frontend/test/__support__/custom-viz-fixtures/calendar-heatmap-fixtures.ts @@ -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); } diff --git a/package.json b/package.json index e0ef26fe2ea..3b8e56e220a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/patches/@locker+near-membrane-dom+0.16.0.patch b/patches/@locker+near-membrane-dom+0.16.0.patch new file mode 100644 index 00000000000..903335912bf --- /dev/null +++ b/patches/@locker+near-membrane-dom+0.16.0.patch @@ -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 ( { ++ 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 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; + 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 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; + 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';