Skip to content

Commit 3b9995a

Browse files
authored
fix: harden trust-bearing window globals and gate script loading (#9330)
Follow-up to #9318. Extends the frozen first-party marker pattern to `__MARIMO_STATIC__` (backs the virtual-file allowlist) and `__MARIMO_MOUNT_CONFIG__`, and adds a shared `hasTrustedNotebookContext()` predicate so `RenderHTML.replaceSrcScripts` refuses notebook-authored `<script src>` in untrusted edit mode before any user interaction.
1 parent cd843d2 commit 3b9995a

17 files changed

Lines changed: 537 additions & 54 deletions

File tree

frontend/index.html

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,11 @@
7272
<!-- This is a portal for the data editor to render in -->
7373
<div id="portal" data-testid="glide-portal" style="position: fixed; left: 0; top: 0; z-index: 9999"></div>
7474
<script data-marimo="true">
75-
window.__MARIMO_MOUNT_CONFIG__ = '{{ mount_config }}';
75+
Object.defineProperty(window, "__MARIMO_MOUNT_CONFIG__", {
76+
value: Object.freeze('{{ mount_config }}'),
77+
writable: false,
78+
configurable: false,
79+
});
7680
</script>
7781
<script type="module" src="/src/main.tsx"></script>
7882
</body>
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

frontend/src/core/static/export-context.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
/* Copyright 2026 Marimo. All rights reserved. */
22

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+
38
export interface MarimoExportContext {
49
trusted: true;
510
notebookCode?: string;
@@ -41,3 +46,39 @@ export function getMarimoExportContext():
4146
export function hasTrustedExportContext(): boolean {
4247
return getMarimoExportContext()?.trusted === true;
4348
}
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+
}

frontend/src/core/static/static-state.ts

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,58 @@ import type { MarimoStaticState, StaticVirtualFiles } from "./types";
55

66
declare global {
77
interface Window {
8-
__MARIMO_STATIC__?: MarimoStaticState;
8+
__MARIMO_STATIC__?: Readonly<MarimoStaticState>;
99
}
1010
}
1111

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+
1250
export function isStaticNotebook(): boolean {
13-
return window?.__MARIMO_STATIC__ !== undefined;
51+
return getMarimoStaticState() !== undefined;
1452
}
1553

1654
export function getStaticVirtualFiles(): StaticVirtualFiles {
17-
invariant(window.__MARIMO_STATIC__ !== undefined, "Not a static notebook");
18-
19-
return window.__MARIMO_STATIC__.files;
55+
const state = getMarimoStaticState();
56+
invariant(state !== undefined, "Not a static notebook");
57+
return state.files;
2058
}
2159

2260
export function getStaticModelNotifications(): ModelLifecycle[] | undefined {
23-
return window?.__MARIMO_STATIC__?.modelNotifications;
61+
return getMarimoStaticState()?.modelNotifications;
2462
}

frontend/src/plugins/core/RenderHTML.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import { CopyClipboardIcon } from "@/components/icons/copy-icon";
1616
import { QueryParamPreservingLink } from "@/components/ui/query-param-preserving-link";
1717
import { Tooltip } from "@/components/ui/tooltip";
1818
import { DocHoverTarget } from "@/core/documentation/DocHoverTarget";
19+
import { hasTrustedNotebookContext } from "@/core/static/export-context";
20+
import { Logger } from "@/utils/Logger";
1921
import { sanitizeHtml, useSanitizeHtml } from "./sanitize";
2022

2123
type ReplacementFn = NonNullable<HTMLReactParserOptions["replace"]>;
@@ -98,8 +100,27 @@ const replaceSrcScripts = (domNode: DOMNode): JSX.Element | undefined => {
98100
if (!src) {
99101
return;
100102
}
101-
// Check if script already exists
102-
if (!document.querySelector(`script[src="${src}"]`)) {
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) {
103124
const script = document.createElement("script");
104125
script.src = src;
105126
document.head.append(script);

0 commit comments

Comments
 (0)