@marimo-team/islands 0.23.1-dev22 → 0.23.1-dev23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/main.js +24 -13
- package/package.json +1 -1
- package/src/plugins/core/__test__/trusted-url.test.ts +48 -0
- package/src/plugins/core/trusted-url.ts +20 -0
- package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +27 -1
- package/src/plugins/impl/anywidget/widget-binding.ts +13 -0
- package/src/plugins/impl/mpl-interactive/MplInteractivePlugin.tsx +21 -0
- package/src/plugins/impl/mpl-interactive/__tests__/MplInteractivePlugin.test.tsx +119 -0
- package/src/plugins/impl/panel/PanelPlugin.tsx +31 -10
- package/src/plugins/impl/panel/__tests__/PanelPlugin.test.ts +60 -0
package/dist/main.js
CHANGED
|
@@ -13512,6 +13512,9 @@ Defaulting to \`null\`.`;
|
|
|
13512
13512
|
let r = e.indexOf("/@file/");
|
|
13513
13513
|
return r === -1 ? null : e.slice(r);
|
|
13514
13514
|
}
|
|
13515
|
+
function isTrustedVirtualFileUrl(e) {
|
|
13516
|
+
return typeof e != "string" || e.length === 0 ? false : /^(\.?\/)?@file\/[^?#]+$/.test(e);
|
|
13517
|
+
}
|
|
13515
13518
|
const experimental = {
|
|
13516
13519
|
invoke: async () => {
|
|
13517
13520
|
let e = "anywidget.invoke not supported in marimo. Please file an issue at https://github.com/marimo-team/marimo/issues";
|
|
@@ -13546,6 +13549,7 @@ Defaulting to \`null\`.`;
|
|
|
13546
13549
|
Logger.debug(`[WidgetDefRegistry] Invalidating module cache for hash=${e}`), __privateGet(this, _e2).delete(e);
|
|
13547
13550
|
}
|
|
13548
13551
|
}, _e2 = new WeakMap(), _WidgetDefRegistry_instances = new WeakSet(), t_fn = async function(e) {
|
|
13552
|
+
if (!isTrustedVirtualFileUrl(e)) throw Error(`Refusing to load anywidget module from untrusted URL: ${String(e)}`);
|
|
13549
13553
|
let r = asRemoteURL(e).toString();
|
|
13550
13554
|
return isStaticNotebook() && (r = resolveVirtualFileURL(r)), import(r).then(async (m2) => {
|
|
13551
13555
|
await m2.__tla;
|
|
@@ -56837,12 +56841,15 @@ ${c}
|
|
|
56837
56841
|
}));
|
|
56838
56842
|
var mplJsLoading = null;
|
|
56839
56843
|
async function ensureMplJs(e) {
|
|
56840
|
-
if (!window.mpl)
|
|
56841
|
-
|
|
56842
|
-
|
|
56843
|
-
|
|
56844
|
-
|
|
56845
|
-
|
|
56844
|
+
if (!window.mpl) {
|
|
56845
|
+
if (!isTrustedVirtualFileUrl(e)) throw Error(`Refusing to load mpl.js from untrusted URL: ${String(e)}`);
|
|
56846
|
+
return mplJsLoading || (mplJsLoading = new Promise((r, c) => {
|
|
56847
|
+
let d = document.createElement("script");
|
|
56848
|
+
d.src = e, d.onload = () => r(), d.onerror = () => {
|
|
56849
|
+
mplJsLoading = null, c(Error("Failed to load mpl.js"));
|
|
56850
|
+
}, document.head.append(d);
|
|
56851
|
+
}), mplJsLoading);
|
|
56852
|
+
}
|
|
56846
56853
|
}
|
|
56847
56854
|
function patchToolbarImages(e, r) {
|
|
56848
56855
|
let c = (e2) => {
|
|
@@ -56868,6 +56875,7 @@ ${c}
|
|
|
56868
56875
|
}), () => d.disconnect();
|
|
56869
56876
|
}
|
|
56870
56877
|
function injectCss(e, r) {
|
|
56878
|
+
if (!isTrustedVirtualFileUrl(r)) return Logger.error(`Refusing to load mpl CSS from untrusted URL: ${String(r)}`), Functions.NOOP;
|
|
56871
56879
|
let c = document.createElement("link");
|
|
56872
56880
|
return c.rel = "stylesheet", c.href = r, e.append(c), () => c.remove();
|
|
56873
56881
|
}
|
|
@@ -57074,7 +57082,7 @@ ${c}
|
|
|
57074
57082
|
}
|
|
57075
57083
|
};
|
|
57076
57084
|
const PanelPlugin = createPlugin("marimo-panel").withData(object({
|
|
57077
|
-
|
|
57085
|
+
extensionUrl: string().nullable(),
|
|
57078
57086
|
docs_json: record(string(), unknown()),
|
|
57079
57087
|
render_json: object({
|
|
57080
57088
|
roots: record(string(), string())
|
|
@@ -57090,8 +57098,14 @@ ${c}
|
|
|
57090
57098
|
function isBokehLoaded() {
|
|
57091
57099
|
return window.Bokeh != null;
|
|
57092
57100
|
}
|
|
57101
|
+
function loadPanelExtension(e) {
|
|
57102
|
+
if (!e) return false;
|
|
57103
|
+
if (!isTrustedVirtualFileUrl(e)) return Logger.error(`Refusing to load Panel extension from untrusted URL: ${String(e)}`), false;
|
|
57104
|
+
let r = document.createElement("script");
|
|
57105
|
+
return r.src = e, document.head.append(r), true;
|
|
57106
|
+
}
|
|
57093
57107
|
var PanelSlot = (e) => {
|
|
57094
|
-
let { data: r, functions: c, host: d } = e, {
|
|
57108
|
+
let { data: r, functions: c, host: d } = e, { extensionUrl: f, docs_json: _, render_json: v } = r, y = (0, import_react.useRef)(null), S = (0, import_react.useRef)(null), E = (0, import_react.useRef)(null), [O, M] = (0, import_react.useState)(false), [I, z] = (0, import_react.useState)(null), Y7 = (0, import_react.useRef)(c);
|
|
57095
57109
|
Y7.current = c;
|
|
57096
57110
|
let G = (0, import_react.useCallback)(() => {
|
|
57097
57111
|
if (!q.current) return;
|
|
@@ -57112,10 +57126,7 @@ ${c}
|
|
|
57112
57126
|
M(true);
|
|
57113
57127
|
return;
|
|
57114
57128
|
}
|
|
57115
|
-
|
|
57116
|
-
let e3 = document.createElement("script");
|
|
57117
|
-
e3.innerHTML = f, document.head.append(e3);
|
|
57118
|
-
}
|
|
57129
|
+
loadPanelExtension(f);
|
|
57119
57130
|
let e2 = setInterval(() => {
|
|
57120
57131
|
isBokehLoaded() && (M(true), clearInterval(e2));
|
|
57121
57132
|
}, 10);
|
|
@@ -68725,7 +68736,7 @@ ${c}
|
|
|
68725
68736
|
return Logger.warn("Failed to get version from mount config"), null;
|
|
68726
68737
|
}
|
|
68727
68738
|
}
|
|
68728
|
-
const marimoVersionAtom = atom(getVersionFromMountConfig() || "0.23.1-
|
|
68739
|
+
const marimoVersionAtom = atom(getVersionFromMountConfig() || "0.23.1-dev23"), showCodeInRunModeAtom = atom(true);
|
|
68729
68740
|
atom(null);
|
|
68730
68741
|
var VIRTUAL_FILE_REGEX = /\/@file\/([^\s"&'/]+)\.([\dA-Za-z]+)/g, VirtualFileTracker = class e {
|
|
68731
68742
|
constructor() {
|
package/package.json
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { isTrustedVirtualFileUrl } from "../trusted-url";
|
|
4
|
+
|
|
5
|
+
describe("isTrustedVirtualFileUrl", () => {
|
|
6
|
+
it.each([
|
|
7
|
+
"./@file/123-mpl.js",
|
|
8
|
+
"./@file/456-mpl.css",
|
|
9
|
+
"@file/789-bokeh.js",
|
|
10
|
+
"/@file/0-empty.txt",
|
|
11
|
+
"./@file/1234-name.with.dots.js",
|
|
12
|
+
])("accepts virtual file path %s", (url) => {
|
|
13
|
+
expect(isTrustedVirtualFileUrl(url)).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it.each([
|
|
17
|
+
// Attack vector from the vulnerability report
|
|
18
|
+
"http://127.0.0.1:8820/poc.js",
|
|
19
|
+
"https://evil.example.com/x.js",
|
|
20
|
+
// Protocol-relative → takes attacker's origin
|
|
21
|
+
"//evil.example.com/x.js",
|
|
22
|
+
// Dangerous schemes
|
|
23
|
+
"javascript:alert(1)",
|
|
24
|
+
"data:text/javascript;base64,YWxlcnQoMSk=",
|
|
25
|
+
"file:///etc/passwd",
|
|
26
|
+
"blob:http://127.0.0.1/abc",
|
|
27
|
+
// Almost-but-not virtual file paths
|
|
28
|
+
"./evil.js",
|
|
29
|
+
"../@file/x.js",
|
|
30
|
+
"./malicious/@file/x.js",
|
|
31
|
+
"@file",
|
|
32
|
+
"@files/x.js",
|
|
33
|
+
// Query/fragment smuggling
|
|
34
|
+
"./@file/x.js?redirect=http://evil.com",
|
|
35
|
+
"./@file/x.js#http://evil.com",
|
|
36
|
+
// Empty and non-string
|
|
37
|
+
"",
|
|
38
|
+
])("rejects %s", (url) => {
|
|
39
|
+
expect(isTrustedVirtualFileUrl(url)).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("rejects non-string input", () => {
|
|
43
|
+
expect(isTrustedVirtualFileUrl(null)).toBe(false);
|
|
44
|
+
expect(isTrustedVirtualFileUrl(undefined)).toBe(false);
|
|
45
|
+
expect(isTrustedVirtualFileUrl(42)).toBe(false);
|
|
46
|
+
expect(isTrustedVirtualFileUrl({})).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether a URL can be trusted to point at a marimo-served virtual file.
|
|
5
|
+
*
|
|
6
|
+
* Plugins that load remote scripts or stylesheets (e.g. MplInteractive, Panel)
|
|
7
|
+
* must call this before turning a plugin-supplied URL into a `<script src>` or
|
|
8
|
+
* `<link href>`. The backend always serializes these URLs as virtual file
|
|
9
|
+
* paths of the form `./@file/<byte_length>-<filename>` (see
|
|
10
|
+
* `VirtualFile.create_and_register`). Accepting anything else would let a
|
|
11
|
+
* maliciously crafted `<marimo-*>` element embedded in markdown load
|
|
12
|
+
* attacker-controlled JavaScript at same origin, since the HTML sanitizer
|
|
13
|
+
* lets arbitrary marimo custom elements and attributes through.
|
|
14
|
+
*/
|
|
15
|
+
export function isTrustedVirtualFileUrl(url: unknown): url is string {
|
|
16
|
+
if (typeof url !== "string" || url.length === 0) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
return /^(\.?\/)?@file\/[^?#]+$/.test(url);
|
|
20
|
+
}
|
|
@@ -59,13 +59,39 @@ describe("WidgetDefRegistry", () => {
|
|
|
59
59
|
|
|
60
60
|
it("should remove from cache on import failure so retry creates new promise", async () => {
|
|
61
61
|
const promise1 = registry.getModule("http://localhost/a.js", "fail-hash");
|
|
62
|
-
// The
|
|
62
|
+
// The URL is rejected by the trusted-URL validator.
|
|
63
63
|
await expect(promise1).rejects.toThrow();
|
|
64
64
|
// After failure, cache should be cleared, so next call creates a new promise
|
|
65
65
|
const promise2 = registry.getModule("http://localhost/a.js", "fail-hash");
|
|
66
66
|
expect(promise1).not.toBe(promise2);
|
|
67
67
|
promise2.catch(() => undefined);
|
|
68
68
|
});
|
|
69
|
+
|
|
70
|
+
describe("URL validation", () => {
|
|
71
|
+
it.each([
|
|
72
|
+
// Attack vector: raw <marimo-anywidget data-js-url=...> in markdown
|
|
73
|
+
"http://127.0.0.1:8820/poc.mjs",
|
|
74
|
+
"https://evil.example.com/widget.mjs",
|
|
75
|
+
"//evil.example.com/widget.mjs",
|
|
76
|
+
"javascript:alert(1)",
|
|
77
|
+
"data:text/javascript;base64,YWxlcnQoMSk=",
|
|
78
|
+
"./@file/x.js?redirect=http://evil.com",
|
|
79
|
+
"",
|
|
80
|
+
])("rejects untrusted URL: %s", async (url) => {
|
|
81
|
+
await expect(registry.getModule(url, `hash-${url}`)).rejects.toThrow(
|
|
82
|
+
/untrusted/i,
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("accepts virtual file paths (fails later at import time)", async () => {
|
|
87
|
+
// The URL passes validation but the import still fails because this
|
|
88
|
+
// is a Node test environment with no server. We only assert that
|
|
89
|
+
// the rejection reason is NOT the "untrusted URL" refusal.
|
|
90
|
+
await expect(
|
|
91
|
+
registry.getModule("./@file/123-widget.js", "trusted-hash"),
|
|
92
|
+
).rejects.not.toThrow(/untrusted/i);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
69
95
|
});
|
|
70
96
|
|
|
71
97
|
describe("WidgetBinding", () => {
|
|
@@ -5,6 +5,7 @@ import type { AnyWidget, Experimental } from "@anywidget/types";
|
|
|
5
5
|
import { asRemoteURL } from "@/core/runtime/config";
|
|
6
6
|
import { resolveVirtualFileURL } from "@/core/static/files";
|
|
7
7
|
import { isStaticNotebook } from "@/core/static/static-state";
|
|
8
|
+
import { isTrustedVirtualFileUrl } from "@/plugins/core/trusted-url";
|
|
8
9
|
import { Logger } from "@/utils/Logger";
|
|
9
10
|
import type { Model } from "./model";
|
|
10
11
|
import type { ModelState, WidgetModelId } from "./types";
|
|
@@ -80,6 +81,18 @@ class WidgetDefRegistry {
|
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
async #doImport(jsUrl: string): Promise<any> {
|
|
84
|
+
// Only trust marimo virtual file paths. Accepting arbitrary URLs
|
|
85
|
+
// would let a raw `<marimo-anywidget data-js-url=...>` element
|
|
86
|
+
// embedded in a markdown cell dynamically import attacker-controlled
|
|
87
|
+
// JavaScript at same origin (the HTML sanitizer allows any marimo-*
|
|
88
|
+
// custom element with any attribute through to the plugin layer).
|
|
89
|
+
if (!isTrustedVirtualFileUrl(jsUrl)) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`Refusing to load anywidget module from untrusted URL: ${String(
|
|
92
|
+
jsUrl,
|
|
93
|
+
)}`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
83
96
|
let url = asRemoteURL(jsUrl).toString();
|
|
84
97
|
if (isStaticNotebook()) {
|
|
85
98
|
url = resolveVirtualFileURL(url);
|
|
@@ -5,12 +5,14 @@ import { useCallback, useEffect, useRef } from "react";
|
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { useEventListener } from "@/hooks/useEventListener";
|
|
7
7
|
import { createPlugin } from "@/plugins/core/builder";
|
|
8
|
+
import { isTrustedVirtualFileUrl } from "@/plugins/core/trusted-url";
|
|
8
9
|
import { MODEL_MANAGER, type Model } from "@/plugins/impl/anywidget/model";
|
|
9
10
|
import type { ModelState, WidgetModelId } from "@/plugins/impl/anywidget/types";
|
|
10
11
|
import type { IPluginProps } from "@/plugins/types";
|
|
11
12
|
import { downloadBlob } from "@/utils/download";
|
|
12
13
|
import { Logger } from "@/utils/Logger";
|
|
13
14
|
import { MplCommWebSocket } from "./mpl-websocket-shim";
|
|
15
|
+
import { Functions } from "@/utils/functions";
|
|
14
16
|
|
|
15
17
|
const MPL_SCOPE_CLASS = "mpl-interactive-figure";
|
|
16
18
|
|
|
@@ -73,6 +75,11 @@ async function ensureMplJs(jsUrl: string): Promise<void> {
|
|
|
73
75
|
if (window.mpl) {
|
|
74
76
|
return;
|
|
75
77
|
}
|
|
78
|
+
if (!isTrustedVirtualFileUrl(jsUrl)) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Refusing to load mpl.js from untrusted URL: ${String(jsUrl)}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
76
83
|
if (mplJsLoading) {
|
|
77
84
|
return mplJsLoading;
|
|
78
85
|
}
|
|
@@ -148,6 +155,12 @@ function patchToolbarImages(
|
|
|
148
155
|
}
|
|
149
156
|
|
|
150
157
|
function injectCss(container: HTMLElement, cssUrl: string): () => void {
|
|
158
|
+
if (!isTrustedVirtualFileUrl(cssUrl)) {
|
|
159
|
+
Logger.error(
|
|
160
|
+
`Refusing to load mpl CSS from untrusted URL: ${String(cssUrl)}`,
|
|
161
|
+
);
|
|
162
|
+
return Functions.NOOP;
|
|
163
|
+
}
|
|
151
164
|
const link = document.createElement("link");
|
|
152
165
|
link.rel = "stylesheet";
|
|
153
166
|
link.href = cssUrl;
|
|
@@ -307,3 +320,11 @@ const MplInteractiveSlot = (props: IPluginProps<ModelIdRef, Data>) => {
|
|
|
307
320
|
// Must match _MPL_SCOPE in from_mpl_interactive.py
|
|
308
321
|
return <div ref={containerRef} className={MPL_SCOPE_CLASS} />;
|
|
309
322
|
};
|
|
323
|
+
|
|
324
|
+
export const visibleForTesting = {
|
|
325
|
+
ensureMplJs,
|
|
326
|
+
injectCss,
|
|
327
|
+
resetMplJsLoading: () => {
|
|
328
|
+
mplJsLoading = null;
|
|
329
|
+
},
|
|
330
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { Logger } from "@/utils/Logger";
|
|
4
|
+
import { visibleForTesting } from "../MplInteractivePlugin";
|
|
5
|
+
|
|
6
|
+
const { ensureMplJs, injectCss, resetMplJsLoading } = visibleForTesting;
|
|
7
|
+
|
|
8
|
+
describe("MplInteractivePlugin URL validation", () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
// Reset module-level script-loading state and any stubs.
|
|
11
|
+
delete (window as { mpl?: unknown }).mpl;
|
|
12
|
+
resetMplJsLoading();
|
|
13
|
+
// Remove any scripts the tests added to document.head.
|
|
14
|
+
for (const el of document.head.querySelectorAll(
|
|
15
|
+
"script[data-test-mpl],link[data-test-mpl]",
|
|
16
|
+
)) {
|
|
17
|
+
el.remove();
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
vi.restoreAllMocks();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("ensureMplJs", () => {
|
|
26
|
+
it("rejects the PoC attack URL without creating a <script>", async () => {
|
|
27
|
+
const appendSpy = vi.spyOn(document.head, "append");
|
|
28
|
+
await expect(ensureMplJs("http://127.0.0.1:8820/poc.js")).rejects.toThrow(
|
|
29
|
+
/untrusted/i,
|
|
30
|
+
);
|
|
31
|
+
expect(appendSpy).not.toHaveBeenCalled();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it.each([
|
|
35
|
+
"https://evil.example.com/x.js",
|
|
36
|
+
"//evil.example.com/x.js",
|
|
37
|
+
"javascript:alert(1)",
|
|
38
|
+
"data:text/javascript;base64,YWxlcnQoMSk=",
|
|
39
|
+
"./@file/x.js?redirect=http://evil.com",
|
|
40
|
+
])("rejects %s", async (url) => {
|
|
41
|
+
const appendSpy = vi.spyOn(document.head, "append");
|
|
42
|
+
await expect(ensureMplJs(url)).rejects.toThrow(/untrusted/i);
|
|
43
|
+
expect(appendSpy).not.toHaveBeenCalled();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("is a no-op when window.mpl is already present", async () => {
|
|
47
|
+
(window as { mpl?: unknown }).mpl = {};
|
|
48
|
+
const appendSpy = vi.spyOn(document.head, "append");
|
|
49
|
+
// Even a malicious URL should be ignored — short-circuit happens first.
|
|
50
|
+
await expect(
|
|
51
|
+
ensureMplJs("http://evil.example.com/x.js"),
|
|
52
|
+
).resolves.toBeUndefined();
|
|
53
|
+
expect(appendSpy).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("creates a <script src> for a trusted virtual file URL", async () => {
|
|
57
|
+
const appendSpy = vi
|
|
58
|
+
.spyOn(document.head, "append")
|
|
59
|
+
.mockImplementation((...nodes) => {
|
|
60
|
+
// Simulate a successful load so ensureMplJs resolves.
|
|
61
|
+
for (const node of nodes) {
|
|
62
|
+
if (node instanceof HTMLScriptElement) {
|
|
63
|
+
queueMicrotask(() => node.onload?.(new Event("load")));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
await expect(ensureMplJs("./@file/123-mpl.js")).resolves.toBeUndefined();
|
|
69
|
+
|
|
70
|
+
expect(appendSpy).toHaveBeenCalledTimes(1);
|
|
71
|
+
const appended = appendSpy.mock.calls[0][0] as HTMLScriptElement;
|
|
72
|
+
expect(appended.tagName).toBe("SCRIPT");
|
|
73
|
+
expect(appended.src).toContain("@file/123-mpl.js");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("injectCss", () => {
|
|
78
|
+
it("refuses to append <link> for the PoC attack CSS URL", () => {
|
|
79
|
+
const container = document.createElement("div");
|
|
80
|
+
const loggerSpy = vi.spyOn(Logger, "error").mockImplementation(() => {});
|
|
81
|
+
|
|
82
|
+
const cleanup = injectCss(container, "http://127.0.0.1:8820/x.css");
|
|
83
|
+
|
|
84
|
+
expect(container.querySelector("link")).toBeNull();
|
|
85
|
+
expect(loggerSpy).toHaveBeenCalledWith(
|
|
86
|
+
expect.stringContaining("untrusted"),
|
|
87
|
+
);
|
|
88
|
+
// Cleanup must be safe to call even when nothing was appended.
|
|
89
|
+
expect(() => cleanup()).not.toThrow();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it.each([
|
|
93
|
+
"https://evil.example.com/x.css",
|
|
94
|
+
"javascript:alert(1)",
|
|
95
|
+
"data:text/css,body{background:red}",
|
|
96
|
+
])("refuses to append <link> for %s", (url) => {
|
|
97
|
+
const container = document.createElement("div");
|
|
98
|
+
vi.spyOn(Logger, "error").mockImplementation(() => {});
|
|
99
|
+
|
|
100
|
+
injectCss(container, url);
|
|
101
|
+
|
|
102
|
+
expect(container.querySelector("link")).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("appends a <link> for a trusted virtual file URL", () => {
|
|
106
|
+
const container = document.createElement("div");
|
|
107
|
+
|
|
108
|
+
const cleanup = injectCss(container, "./@file/456-mpl.css");
|
|
109
|
+
|
|
110
|
+
const link = container.querySelector("link");
|
|
111
|
+
expect(link).not.toBeNull();
|
|
112
|
+
expect(link?.rel).toBe("stylesheet");
|
|
113
|
+
expect(link?.getAttribute("href")).toBe("./@file/456-mpl.css");
|
|
114
|
+
|
|
115
|
+
cleanup();
|
|
116
|
+
expect(container.querySelector("link")).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from "@/hooks/useEventListener";
|
|
11
11
|
import { createPlugin } from "@/plugins/core/builder";
|
|
12
12
|
import { rpc } from "@/plugins/core/rpc";
|
|
13
|
+
import { isTrustedVirtualFileUrl } from "@/plugins/core/trusted-url";
|
|
13
14
|
import type { IPluginProps } from "@/plugins/types";
|
|
14
15
|
import { Logger } from "@/utils/Logger";
|
|
15
16
|
import { EventBuffer, extractBuffers, MessageSchema } from "./utils";
|
|
@@ -64,7 +65,7 @@ declare global {
|
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
interface PanelData {
|
|
67
|
-
|
|
68
|
+
extensionUrl: string | null;
|
|
68
69
|
docs_json: Record<string, unknown>;
|
|
69
70
|
render_json: {
|
|
70
71
|
roots: Record<string, string>;
|
|
@@ -85,7 +86,7 @@ type PluginFunctions = {
|
|
|
85
86
|
export const PanelPlugin = createPlugin<T>("marimo-panel")
|
|
86
87
|
.withData(
|
|
87
88
|
z.object({
|
|
88
|
-
|
|
89
|
+
extensionUrl: z.string().nullable(),
|
|
89
90
|
docs_json: z.record(z.string(), z.unknown()),
|
|
90
91
|
render_json: z
|
|
91
92
|
.object({
|
|
@@ -110,9 +111,34 @@ function isBokehLoaded() {
|
|
|
110
111
|
return window.Bokeh != null;
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Append a `<script src>` for the bokeh/panel extension.
|
|
116
|
+
*
|
|
117
|
+
* The URL must be a marimo virtual file path; anything else (e.g. an
|
|
118
|
+
* attacker-controlled URL injected via a raw `<marimo-panel>` element in a
|
|
119
|
+
* markdown cell) is refused.
|
|
120
|
+
*/
|
|
121
|
+
export function loadPanelExtension(extensionUrl: string | null): boolean {
|
|
122
|
+
if (!extensionUrl) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
if (!isTrustedVirtualFileUrl(extensionUrl)) {
|
|
126
|
+
Logger.error(
|
|
127
|
+
`Refusing to load Panel extension from untrusted URL: ${String(
|
|
128
|
+
extensionUrl,
|
|
129
|
+
)}`,
|
|
130
|
+
);
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
const script = document.createElement("script");
|
|
134
|
+
script.src = extensionUrl;
|
|
135
|
+
document.head.append(script);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
113
139
|
const PanelSlot = (props: Props) => {
|
|
114
140
|
const { data, functions, host } = props;
|
|
115
|
-
const {
|
|
141
|
+
const { extensionUrl, docs_json: docsJson, render_json: renderJson } = data;
|
|
116
142
|
const ref = useRef<HTMLDivElement>(null);
|
|
117
143
|
const rootModelIdRef = useRef<string | null>(null);
|
|
118
144
|
const receiverRef = useRef<InstanceType<
|
|
@@ -173,12 +199,7 @@ const PanelSlot = (props: Props) => {
|
|
|
173
199
|
return;
|
|
174
200
|
}
|
|
175
201
|
|
|
176
|
-
|
|
177
|
-
if (extension) {
|
|
178
|
-
const script = document.createElement("script");
|
|
179
|
-
script.innerHTML = extension;
|
|
180
|
-
document.head.append(script);
|
|
181
|
-
}
|
|
202
|
+
loadPanelExtension(extensionUrl);
|
|
182
203
|
|
|
183
204
|
// Check if Bokeh is loaded every 10ms
|
|
184
205
|
const checkBokeh = setInterval(() => {
|
|
@@ -189,7 +210,7 @@ const PanelSlot = (props: Props) => {
|
|
|
189
210
|
}, 10);
|
|
190
211
|
|
|
191
212
|
return () => clearInterval(checkBokeh);
|
|
192
|
-
}, [
|
|
213
|
+
}, [extensionUrl, setLoaded]);
|
|
193
214
|
|
|
194
215
|
// Listen for incoming messages
|
|
195
216
|
useEventListener(
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { Logger } from "@/utils/Logger";
|
|
4
|
+
import { loadPanelExtension } from "../PanelPlugin";
|
|
5
|
+
|
|
6
|
+
describe("loadPanelExtension", () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
for (const el of document.head.querySelectorAll("script")) {
|
|
9
|
+
el.remove();
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.restoreAllMocks();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("does nothing and returns false for null URL", () => {
|
|
18
|
+
const appendSpy = vi.spyOn(document.head, "append");
|
|
19
|
+
expect(loadPanelExtension(null)).toBe(false);
|
|
20
|
+
expect(appendSpy).not.toHaveBeenCalled();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("refuses to load the PoC attack URL", () => {
|
|
24
|
+
const appendSpy = vi.spyOn(document.head, "append");
|
|
25
|
+
const loggerSpy = vi.spyOn(Logger, "error").mockImplementation(() => {});
|
|
26
|
+
|
|
27
|
+
expect(loadPanelExtension("http://127.0.0.1:8820/poc.js")).toBe(false);
|
|
28
|
+
|
|
29
|
+
expect(appendSpy).not.toHaveBeenCalled();
|
|
30
|
+
expect(loggerSpy).toHaveBeenCalledWith(
|
|
31
|
+
expect.stringContaining("untrusted"),
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it.each([
|
|
36
|
+
"https://evil.example.com/x.js",
|
|
37
|
+
"//evil.example.com/x.js",
|
|
38
|
+
// An attacker embedding inline JS as a data URL — what the old plugin
|
|
39
|
+
// would have executed verbatim via script.innerHTML.
|
|
40
|
+
"data:text/javascript;base64,YWxlcnQoMSk=",
|
|
41
|
+
"javascript:alert(1)",
|
|
42
|
+
"./@file/x.js#http://evil.com",
|
|
43
|
+
])("refuses to load %s", (url) => {
|
|
44
|
+
const appendSpy = vi.spyOn(document.head, "append");
|
|
45
|
+
vi.spyOn(Logger, "error").mockImplementation(() => {});
|
|
46
|
+
|
|
47
|
+
expect(loadPanelExtension(url)).toBe(false);
|
|
48
|
+
expect(appendSpy).not.toHaveBeenCalled();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("appends a <script src> for a trusted virtual file URL", () => {
|
|
52
|
+
expect(loadPanelExtension("./@file/42-bokeh.js")).toBe(true);
|
|
53
|
+
|
|
54
|
+
const script = document.head.querySelector("script");
|
|
55
|
+
expect(script).not.toBeNull();
|
|
56
|
+
expect(script?.src).toContain("@file/42-bokeh.js");
|
|
57
|
+
// Must NOT populate innerHTML — that was the original vulnerability sink.
|
|
58
|
+
expect(script?.innerHTML).toBe("");
|
|
59
|
+
});
|
|
60
|
+
});
|