@marimo-team/frontend 0.23.3-dev39 → 0.23.3-dev41
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/assets/JsonOutput-zJfSu-Vk.js +49 -0
- package/dist/assets/{MarimoErrorOutput-BWFFx2VQ.js → MarimoErrorOutput-BGCTswmw.js} +1 -1
- package/dist/assets/RenderHTML-DT8P6T1l.js +1 -0
- package/dist/assets/{add-connection-dialog-C1aIq9FP.js → add-connection-dialog-DChyqh-3.js} +1 -1
- package/dist/assets/{agent-panel-BF6fYTEU.js → agent-panel-CcLuIYse.js} +1 -1
- package/dist/assets/{cell-editor-DL4womh8.js → cell-editor-BqklzNDV.js} +1 -1
- package/dist/assets/{chat-display-DyTcEZFU.js → chat-display-7P_28VVE.js} +1 -1
- package/dist/assets/{chat-panel-DVFjhDIb.js → chat-panel-BQp1b73y.js} +1 -1
- package/dist/assets/{chat-ui-CyIhz4-T.js → chat-ui-BNeL7YZB.js} +1 -1
- package/dist/assets/{column-preview-rQE4qc5R.js → column-preview-C2_n8Cec.js} +1 -1
- package/dist/assets/{command-palette-Cd2q-HUO.js → command-palette-Ck1uQB30.js} +1 -1
- package/dist/assets/{documentation-panel-CZt-M5JN.js → documentation-panel-BJnrDC1G.js} +1 -1
- package/dist/assets/{edit-page-BQ7AdKwV.js → edit-page-BUArB_ce.js} +3 -3
- package/dist/assets/{error-panel-BN05vshu.js → error-panel-C3s8IlRa.js} +1 -1
- package/dist/assets/{file-explorer-panel-Ci0Cz4JU.js → file-explorer-panel-Dk5S33G1.js} +1 -1
- package/dist/assets/{form-BON4sAdY.js → form-kVlV8UJh.js} +1 -1
- package/dist/assets/{hooks-CLXoEljg.js → hooks-CVOMPk9n.js} +1 -1
- package/dist/assets/{index-Flgwmpg_.js → index-AQQQ__hU.js} +4 -4
- package/dist/assets/{layout-B31_eP6P.js → layout-BZFYst8S.js} +3 -3
- package/dist/assets/{panels-CfgYkAvQ.js → panels-Dp6Q1Mvz.js} +1 -1
- package/dist/assets/{reveal-component-Bt3Epro6.js → reveal-component-DWnqsE1_.js} +1 -1
- package/dist/assets/{run-page-4lA-192f.js → run-page-DiTeOCSw.js} +1 -1
- package/dist/assets/{scratchpad-panel-CiXkVXhL.js → scratchpad-panel-DDqb_fyf.js} +1 -1
- package/dist/assets/{session-panel-oGdw6hvF.js → session-panel-NjZNmi9U.js} +1 -1
- package/dist/assets/{slide-C5wPx0qL.js → slide-C5Pp2eO8.js} +1 -1
- package/dist/assets/{snippets-panel-C1g0FaLN.js → snippets-panel-BYaK2OYv.js} +1 -1
- package/dist/assets/state-BHl5TPGk.js +3 -0
- package/dist/assets/{useNotebookActions-C-Vve9tT.js → useNotebookActions-Cg9IU5j7.js} +1 -1
- package/dist/index.html +12 -8
- package/package.json +1 -1
- package/src/core/static/__tests__/export-context.test.ts +122 -0
- package/src/core/static/__tests__/static-state.test.ts +80 -0
- package/src/core/static/export-context.ts +41 -0
- package/src/core/static/static-state.ts +44 -6
- package/src/plugins/core/RenderHTML.tsx +23 -2
- package/src/plugins/core/__test__/RenderHTML.test.ts +86 -1
- package/src/plugins/core/sanitize.ts +11 -5
- package/src/plugins/core/trusted-url.ts +9 -0
- package/dist/assets/JsonOutput-BLiYwyAC.js +0 -49
- package/dist/assets/RenderHTML-DweIff7M.js +0 -1
- package/dist/assets/state-DpMLCkRP.js +0 -3
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
// @vitest-environment jsdom
|
|
3
|
+
|
|
4
|
+
import type { ExtractAtomValue } from "jotai";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { hasRunAnyCellAtom } from "@/components/editor/cell/useRunCells";
|
|
7
|
+
import { userConfigAtom } from "@/core/config/config";
|
|
8
|
+
import { parseUserConfig } from "@/core/config/config-schema";
|
|
9
|
+
import { initialModeAtom } from "@/core/mode";
|
|
10
|
+
import { store } from "@/core/state/jotai";
|
|
11
|
+
import {
|
|
12
|
+
getMarimoExportContext,
|
|
13
|
+
hasTrustedExportContext,
|
|
14
|
+
hasTrustedNotebookContext,
|
|
15
|
+
} from "../export-context";
|
|
16
|
+
|
|
17
|
+
type ExportContextWindow = Window & {
|
|
18
|
+
__MARIMO_EXPORT_CONTEXT__?: {
|
|
19
|
+
trusted: boolean;
|
|
20
|
+
notebookCode?: string;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function setAutoInstantiate(value: boolean) {
|
|
25
|
+
const cleared = parseUserConfig({});
|
|
26
|
+
store.set(userConfigAtom, {
|
|
27
|
+
...cleared,
|
|
28
|
+
runtime: { ...cleared.runtime, auto_instantiate: value },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("hasTrustedNotebookContext", () => {
|
|
33
|
+
let previousHasRunAnyCell: ExtractAtomValue<typeof hasRunAnyCellAtom>;
|
|
34
|
+
let previousConfig: ExtractAtomValue<typeof userConfigAtom>;
|
|
35
|
+
let previousMode: ExtractAtomValue<typeof initialModeAtom>;
|
|
36
|
+
let w: ExportContextWindow;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
w = window as ExportContextWindow;
|
|
40
|
+
previousHasRunAnyCell = store.get(hasRunAnyCellAtom);
|
|
41
|
+
previousConfig = store.get(userConfigAtom);
|
|
42
|
+
previousMode = store.get(initialModeAtom);
|
|
43
|
+
store.set(hasRunAnyCellAtom, false);
|
|
44
|
+
setAutoInstantiate(false);
|
|
45
|
+
store.set(initialModeAtom, "edit");
|
|
46
|
+
delete w.__MARIMO_EXPORT_CONTEXT__;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
store.set(hasRunAnyCellAtom, previousHasRunAnyCell);
|
|
51
|
+
store.set(userConfigAtom, previousConfig);
|
|
52
|
+
store.set(initialModeAtom, previousMode);
|
|
53
|
+
delete w.__MARIMO_EXPORT_CONTEXT__;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("returns false in untrusted edit mode before interaction", () => {
|
|
57
|
+
expect(hasTrustedNotebookContext()).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns true once the user has run a cell", () => {
|
|
61
|
+
store.set(hasRunAnyCellAtom, true);
|
|
62
|
+
expect(hasTrustedNotebookContext()).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns true when a trusted export context is installed", () => {
|
|
66
|
+
w.__MARIMO_EXPORT_CONTEXT__ = { trusted: true };
|
|
67
|
+
expect(hasTrustedNotebookContext()).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("returns true when auto_instantiate is enabled", () => {
|
|
71
|
+
setAutoInstantiate(true);
|
|
72
|
+
expect(hasTrustedNotebookContext()).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns true in read mode", () => {
|
|
76
|
+
store.set(initialModeAtom, "read");
|
|
77
|
+
expect(hasTrustedNotebookContext()).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("returns false if initialMode throws (config not yet applied)", () => {
|
|
81
|
+
store.set(initialModeAtom, undefined);
|
|
82
|
+
expect(hasTrustedNotebookContext()).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("hasTrustedExportContext / getMarimoExportContext shape validation", () => {
|
|
87
|
+
let w: ExportContextWindow;
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
w = window as ExportContextWindow;
|
|
91
|
+
delete w.__MARIMO_EXPORT_CONTEXT__;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
delete w.__MARIMO_EXPORT_CONTEXT__;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("accepts a valid context", () => {
|
|
99
|
+
w.__MARIMO_EXPORT_CONTEXT__ = { trusted: true, notebookCode: "x = 1" };
|
|
100
|
+
expect(hasTrustedExportContext()).toBe(true);
|
|
101
|
+
expect(getMarimoExportContext()).toEqual({
|
|
102
|
+
trusted: true,
|
|
103
|
+
notebookCode: "x = 1",
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("rejects a context where `trusted` is not exactly true", () => {
|
|
108
|
+
w.__MARIMO_EXPORT_CONTEXT__ = {
|
|
109
|
+
trusted: "yes" as unknown as true,
|
|
110
|
+
};
|
|
111
|
+
expect(hasTrustedExportContext()).toBe(false);
|
|
112
|
+
expect(getMarimoExportContext()).toBeUndefined();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("rejects a context with non-string notebookCode", () => {
|
|
116
|
+
w.__MARIMO_EXPORT_CONTEXT__ = {
|
|
117
|
+
trusted: true,
|
|
118
|
+
notebookCode: 42 as unknown as string,
|
|
119
|
+
};
|
|
120
|
+
expect(getMarimoExportContext()).toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
// @vitest-environment jsdom
|
|
3
|
+
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
getStaticModelNotifications,
|
|
7
|
+
getStaticVirtualFiles,
|
|
8
|
+
isStaticNotebook,
|
|
9
|
+
} from "../static-state";
|
|
10
|
+
|
|
11
|
+
function setMarimoStatic(value: unknown): void {
|
|
12
|
+
(window as unknown as { __MARIMO_STATIC__?: unknown }).__MARIMO_STATIC__ =
|
|
13
|
+
value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function clearMarimoStatic(): void {
|
|
17
|
+
delete (window as unknown as { __MARIMO_STATIC__?: unknown })
|
|
18
|
+
.__MARIMO_STATIC__;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("static-state shape validation", () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
clearMarimoStatic();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
clearMarimoStatic();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("treats an absent global as not-a-static-notebook", () => {
|
|
31
|
+
expect(isStaticNotebook()).toBe(false);
|
|
32
|
+
expect(getStaticModelNotifications()).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("accepts a well-formed state", () => {
|
|
36
|
+
setMarimoStatic({
|
|
37
|
+
files: { "/@file/a.txt": "data:text/plain;base64,YQ==" },
|
|
38
|
+
modelNotifications: [],
|
|
39
|
+
});
|
|
40
|
+
expect(isStaticNotebook()).toBe(true);
|
|
41
|
+
expect(getStaticVirtualFiles()).toEqual({
|
|
42
|
+
"/@file/a.txt": "data:text/plain;base64,YQ==",
|
|
43
|
+
});
|
|
44
|
+
expect(getStaticModelNotifications()).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("rejects a malformed global (missing files)", () => {
|
|
48
|
+
setMarimoStatic({ modelNotifications: [] });
|
|
49
|
+
expect(isStaticNotebook()).toBe(false);
|
|
50
|
+
expect(getStaticModelNotifications()).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("rejects a malformed global (non-array modelNotifications)", () => {
|
|
54
|
+
setMarimoStatic({ files: {}, modelNotifications: "oops" });
|
|
55
|
+
expect(isStaticNotebook()).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("rejects a non-object global", () => {
|
|
59
|
+
setMarimoStatic("pwned");
|
|
60
|
+
expect(isStaticNotebook()).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("rejects an array as the state", () => {
|
|
64
|
+
setMarimoStatic([]);
|
|
65
|
+
expect(isStaticNotebook()).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("rejects files when it is an array", () => {
|
|
69
|
+
setMarimoStatic({ files: [], modelNotifications: [] });
|
|
70
|
+
expect(isStaticNotebook()).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("rejects files that contain non-string values", () => {
|
|
74
|
+
setMarimoStatic({
|
|
75
|
+
files: { "/@file/a.txt": 42 },
|
|
76
|
+
modelNotifications: [],
|
|
77
|
+
});
|
|
78
|
+
expect(isStaticNotebook()).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
+
import { hasRunAnyCellAtom } from "@/components/editor/cell/useRunCells";
|
|
4
|
+
import { autoInstantiateAtom } from "@/core/config/config";
|
|
5
|
+
import { getInitialAppMode } from "@/core/mode";
|
|
6
|
+
import { store } from "@/core/state/jotai";
|
|
7
|
+
|
|
3
8
|
export interface MarimoExportContext {
|
|
4
9
|
trusted: true;
|
|
5
10
|
notebookCode?: string;
|
|
@@ -41,3 +46,39 @@ export function getMarimoExportContext():
|
|
|
41
46
|
export function hasTrustedExportContext(): boolean {
|
|
42
47
|
return getMarimoExportContext()?.trusted === true;
|
|
43
48
|
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* True when the current page is a context where notebook-authored script
|
|
52
|
+
* execution is expected, and therefore the user has consented (explicitly or
|
|
53
|
+
* by the nature of the page) to running arbitrary notebook content:
|
|
54
|
+
*
|
|
55
|
+
* - the user has run at least one cell, OR
|
|
56
|
+
* - a first-party exported notebook page installed a trusted export context
|
|
57
|
+
* (islands / static exports / Quarto islands), OR
|
|
58
|
+
* - `auto_instantiate` is enabled (the notebook runs on page load by user
|
|
59
|
+
* configuration), OR
|
|
60
|
+
* - the page was loaded in `read` / app mode (served by marimo as an app).
|
|
61
|
+
*
|
|
62
|
+
* Edit mode before any user interaction is intentionally NOT trusted — that
|
|
63
|
+
* is the only surface where we must prevent notebook-authored content from
|
|
64
|
+
* loading scripts or bypassing HTML sanitization.
|
|
65
|
+
*/
|
|
66
|
+
export function hasTrustedNotebookContext(): boolean {
|
|
67
|
+
if (store.get(hasRunAnyCellAtom)) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
if (hasTrustedExportContext()) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
if (store.get(autoInstantiateAtom)) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
if (getInitialAppMode() === "read") {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// getInitialAppMode throws before mount config is applied; treat as untrusted.
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
@@ -5,20 +5,58 @@ import type { MarimoStaticState, StaticVirtualFiles } from "./types";
|
|
|
5
5
|
|
|
6
6
|
declare global {
|
|
7
7
|
interface Window {
|
|
8
|
-
__MARIMO_STATIC__?: MarimoStaticState
|
|
8
|
+
__MARIMO_STATIC__?: Readonly<MarimoStaticState>;
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
function isStringToStringRecord(
|
|
13
|
+
value: unknown,
|
|
14
|
+
): value is Record<string, string> {
|
|
15
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
for (const entry of Object.values(value)) {
|
|
19
|
+
if (typeof entry !== "string") {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isMarimoStaticState(
|
|
27
|
+
value: unknown,
|
|
28
|
+
): value is Readonly<MarimoStaticState> {
|
|
29
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
const candidate = value as MarimoStaticState;
|
|
33
|
+
if (!isStringToStringRecord(candidate.files)) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
if (
|
|
37
|
+
candidate.modelNotifications !== undefined &&
|
|
38
|
+
!Array.isArray(candidate.modelNotifications)
|
|
39
|
+
) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getMarimoStaticState(): Readonly<MarimoStaticState> | undefined {
|
|
46
|
+
const state = window?.__MARIMO_STATIC__;
|
|
47
|
+
return isMarimoStaticState(state) ? state : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
12
50
|
export function isStaticNotebook(): boolean {
|
|
13
|
-
return
|
|
51
|
+
return getMarimoStaticState() !== undefined;
|
|
14
52
|
}
|
|
15
53
|
|
|
16
54
|
export function getStaticVirtualFiles(): StaticVirtualFiles {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return
|
|
55
|
+
const state = getMarimoStaticState();
|
|
56
|
+
invariant(state !== undefined, "Not a static notebook");
|
|
57
|
+
return state.files;
|
|
20
58
|
}
|
|
21
59
|
|
|
22
60
|
export function getStaticModelNotifications(): ModelLifecycle[] | undefined {
|
|
23
|
-
return
|
|
61
|
+
return getMarimoStaticState()?.modelNotifications;
|
|
24
62
|
}
|
|
@@ -16,6 +16,8 @@ import { CopyClipboardIcon } from "@/components/icons/copy-icon";
|
|
|
16
16
|
import { QueryParamPreservingLink } from "@/components/ui/query-param-preserving-link";
|
|
17
17
|
import { Tooltip } from "@/components/ui/tooltip";
|
|
18
18
|
import { DocHoverTarget } from "@/core/documentation/DocHoverTarget";
|
|
19
|
+
import { hasTrustedNotebookContext } from "@/core/static/export-context";
|
|
20
|
+
import { Logger } from "@/utils/Logger";
|
|
19
21
|
import { sanitizeHtml, useSanitizeHtml } from "./sanitize";
|
|
20
22
|
|
|
21
23
|
type ReplacementFn = NonNullable<HTMLReactParserOptions["replace"]>;
|
|
@@ -98,8 +100,27 @@ const replaceSrcScripts = (domNode: DOMNode): JSX.Element | undefined => {
|
|
|
98
100
|
if (!src) {
|
|
99
101
|
return;
|
|
100
102
|
}
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
+
// Only append notebook-authored scripts when the page is a trusted
|
|
104
|
+
// context (the user has run a cell, the page is a trusted export, or
|
|
105
|
+
// we're running in read/app mode). In untrusted edit mode before any
|
|
106
|
+
// user interaction, drop the script and log a warning. Outer
|
|
107
|
+
// sanitization will normally strip <script> tags already; this is
|
|
108
|
+
// defense-in-depth for flows that reparse children with
|
|
109
|
+
// alwaysSanitizeHtml: false (see registerReactComponent.getChildren).
|
|
110
|
+
if (!hasTrustedNotebookContext()) {
|
|
111
|
+
Logger.warn(
|
|
112
|
+
`[RenderHTML] refusing <script src> in untrusted context: ${src}`,
|
|
113
|
+
);
|
|
114
|
+
// oxlint-disable-next-line react/jsx-no-useless-fragment
|
|
115
|
+
return <></>;
|
|
116
|
+
}
|
|
117
|
+
// Check if script already exists. Avoid building a CSS selector from
|
|
118
|
+
// notebook-provided input, which can throw for valid URLs containing
|
|
119
|
+
// selector-significant characters (e.g. IPv6 hosts with `[`/`]`).
|
|
120
|
+
const scriptExists = [...document.querySelectorAll("script[src]")].some(
|
|
121
|
+
(existingScript) => existingScript.getAttribute("src") === src,
|
|
122
|
+
);
|
|
123
|
+
if (!scriptExists) {
|
|
103
124
|
const script = document.createElement("script");
|
|
104
125
|
script.src = src;
|
|
105
126
|
document.head.append(script);
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
-
import {
|
|
2
|
+
import type { ExtractAtomValue } from "jotai";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
4
|
+
import { hasRunAnyCellAtom } from "@/components/editor/cell/useRunCells";
|
|
5
|
+
import { userConfigAtom } from "@/core/config/config";
|
|
6
|
+
import { parseUserConfig } from "@/core/config/config-schema";
|
|
7
|
+
import { initialModeAtom } from "@/core/mode";
|
|
8
|
+
import { store } from "@/core/state/jotai";
|
|
3
9
|
import { visibleForTesting } from "../RenderHTML";
|
|
4
10
|
|
|
5
11
|
const { parseHtml } = visibleForTesting;
|
|
@@ -197,6 +203,85 @@ describe("parseHtml", () => {
|
|
|
197
203
|
});
|
|
198
204
|
});
|
|
199
205
|
|
|
206
|
+
describe("replaceSrcScripts trust gate", () => {
|
|
207
|
+
let previousHasRunAnyCell: ExtractAtomValue<typeof hasRunAnyCellAtom>;
|
|
208
|
+
let previousConfig: ExtractAtomValue<typeof userConfigAtom>;
|
|
209
|
+
let previousMode: ExtractAtomValue<typeof initialModeAtom>;
|
|
210
|
+
const windowWithExport = window as Window & {
|
|
211
|
+
__MARIMO_EXPORT_CONTEXT__?: unknown;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
function clearTrustSignals() {
|
|
215
|
+
const cleared = parseUserConfig({});
|
|
216
|
+
store.set(hasRunAnyCellAtom, false);
|
|
217
|
+
store.set(userConfigAtom, {
|
|
218
|
+
...cleared,
|
|
219
|
+
runtime: { ...cleared.runtime, auto_instantiate: false },
|
|
220
|
+
});
|
|
221
|
+
store.set(initialModeAtom, "edit");
|
|
222
|
+
delete windowWithExport.__MARIMO_EXPORT_CONTEXT__;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
beforeEach(() => {
|
|
226
|
+
previousHasRunAnyCell = store.get(hasRunAnyCellAtom);
|
|
227
|
+
previousConfig = store.get(userConfigAtom);
|
|
228
|
+
previousMode = store.get(initialModeAtom);
|
|
229
|
+
clearTrustSignals();
|
|
230
|
+
for (const s of document.head.querySelectorAll(
|
|
231
|
+
'script[src^="https://cdn.example.com/"]',
|
|
232
|
+
)) {
|
|
233
|
+
s.remove();
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
afterEach(() => {
|
|
238
|
+
store.set(hasRunAnyCellAtom, previousHasRunAnyCell);
|
|
239
|
+
store.set(userConfigAtom, previousConfig);
|
|
240
|
+
store.set(initialModeAtom, previousMode);
|
|
241
|
+
delete windowWithExport.__MARIMO_EXPORT_CONTEXT__;
|
|
242
|
+
for (const s of document.head.querySelectorAll(
|
|
243
|
+
'script[src^="https://cdn.example.com/"]',
|
|
244
|
+
)) {
|
|
245
|
+
s.remove();
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("drops <script src> in untrusted edit mode", () => {
|
|
250
|
+
parseHtml({
|
|
251
|
+
html: '<script src="https://cdn.example.com/unrun.js"></script>',
|
|
252
|
+
});
|
|
253
|
+
expect(
|
|
254
|
+
document.head.querySelector(
|
|
255
|
+
'script[src="https://cdn.example.com/unrun.js"]',
|
|
256
|
+
),
|
|
257
|
+
).toBeNull();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("loads <script src> once a cell has been run", () => {
|
|
261
|
+
store.set(hasRunAnyCellAtom, true);
|
|
262
|
+
parseHtml({
|
|
263
|
+
html: '<script src="https://cdn.example.com/ok.js"></script>',
|
|
264
|
+
});
|
|
265
|
+
expect(
|
|
266
|
+
document.head.querySelector(
|
|
267
|
+
'script[src="https://cdn.example.com/ok.js"]',
|
|
268
|
+
),
|
|
269
|
+
).not.toBeNull();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("loads <script src> when a trusted export context is installed", () => {
|
|
273
|
+
windowWithExport.__MARIMO_EXPORT_CONTEXT__ = { trusted: true };
|
|
274
|
+
parseHtml({
|
|
275
|
+
html: '<script src="https://cdn.example.com/export.js"></script>',
|
|
276
|
+
});
|
|
277
|
+
expect(
|
|
278
|
+
document.head.querySelector(
|
|
279
|
+
'script[src="https://cdn.example.com/export.js"]',
|
|
280
|
+
),
|
|
281
|
+
).not.toBeNull();
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
200
285
|
describe("wrapTooltipTargets", () => {
|
|
201
286
|
test("data-tooltip wraps element in Tooltip component", () => {
|
|
202
287
|
const html = '<span data-tooltip="Hello world">Hover me</span>';
|
|
@@ -3,25 +3,31 @@ import { atom, useAtomValue } from "jotai";
|
|
|
3
3
|
import { hasRunAnyCellAtom } from "@/components/editor/cell/useRunCells";
|
|
4
4
|
import { autoInstantiateAtom } from "@/core/config/config";
|
|
5
5
|
import { getInitialAppMode } from "@/core/mode";
|
|
6
|
+
import { hasTrustedExportContext } from "@/core/static/export-context";
|
|
6
7
|
|
|
7
8
|
// Re-export so existing consumers don't break.
|
|
8
9
|
export { sanitizeHtml } from "./sanitize-html";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
|
-
* Whether to sanitize the html.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* Whether to sanitize the html. Trust signals match
|
|
13
|
+
* `hasTrustedNotebookContext` in `@/core/static/export-context`.
|
|
14
|
+
* Kept as a jotai atom so consumers re-render when `hasRunAnyCellAtom`
|
|
15
|
+
* flips after the user runs a cell.
|
|
14
16
|
*/
|
|
15
17
|
const sanitizeHtmlAtom = atom<boolean>((get) => {
|
|
16
18
|
const hasRunAnyCell = get(hasRunAnyCellAtom);
|
|
17
19
|
const autoInstantiate = get(autoInstantiateAtom);
|
|
18
20
|
|
|
19
|
-
// If a user has specifically run at least one cell or auto_instantiate is enabled, we don't need to sanitize.
|
|
20
|
-
// HTML needs to be rich to allow for interactive widgets and other dynamic content.
|
|
21
21
|
if (hasRunAnyCell || autoInstantiate) {
|
|
22
22
|
return false;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
// Trusted export context is installed once at page load by a first-party
|
|
26
|
+
// script (frozen + non-configurable), so a direct read is safe and stable.
|
|
27
|
+
if (hasTrustedExportContext()) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
25
31
|
let isInAppMode = true;
|
|
26
32
|
try {
|
|
27
33
|
isInAppMode = getInitialAppMode() === "read";
|
|
@@ -37,6 +37,15 @@ export function isTrustedVirtualFileUrl(url: unknown): url is string {
|
|
|
37
37
|
return false;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Intentionally narrower than `hasTrustedNotebookContext` in
|
|
42
|
+
* `@/core/static/export-context`: `auto_instantiate` and `read` mode are
|
|
43
|
+
* deliberately excluded here. Both can be triggered by DOM-observable page
|
|
44
|
+
* shape, and accepting inline base64 `data:` JS/CSS payloads on their
|
|
45
|
+
* strength would let a hostile notebook page smuggle attacker-controlled
|
|
46
|
+
* script into the same origin. Keep this gate tied only to "user actively
|
|
47
|
+
* ran a cell" or "first-party exporter installed a trusted runtime marker".
|
|
48
|
+
*/
|
|
40
49
|
function hasNotebookTrustedDataUrlContext(): boolean {
|
|
41
50
|
return store.get(hasRunAnyCellAtom) || hasTrustedExportContext();
|
|
42
51
|
}
|