feat(html-embed): sandbox the embed block; split trusted trackers into an admin field

Convert the htmlEmbed node from same-origin raw-HTML execution to a sandboxed
iframe (sandbox="allow-scripts allow-popups allow-forms", no allow-same-origin,
srcdoc) with postMessage auto-resize (validated by event.source) and an optional
manual height attr. The block now runs in an opaque origin and cannot reach the
viewer's cookies/session/API, so it is safe for any member.

Because the block is now harmless, remove the entire admin/role gating apparatus:
drop htmlEmbedAllowed/canAuthorHtmlEmbed/stripDisallowedHtmlEmbedNodes/
collectHtmlEmbedSources and every role-based strip on the write paths (collab
REST/MCP + socket, page create/duplicate, import x2, transclusion unsync), along
with the now-unused WorkspaceRepo/UserRepo injections and the PageService.create
callerRole param. Keep one strip: prepareContentForShare still removes htmlEmbed
on the anonymous public-share read path when the workspace master toggle is OFF.

The workspace settings.htmlEmbed toggle is now a plain feature switch (gates the
slash-menu and share rendering); when ON the block is available to all members.

Add settings.trackerHead: an admin-only raw HTML/JS analytics snippet injected
verbatim into the <head> of public share pages only (ShareSeoController), for
trackers that genuinely need same-origin. Admin-gated via the existing CASL
Manage/Settings ability; never injected into the authenticated app shell.

Closes security-review findings #1, #2, #4, #5, #10 (and #3 as a security issue).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude_code
2026-06-21 02:48:41 +03:00
parent b98c9d51c6
commit 81823fce1e
35 changed files with 482 additions and 1387 deletions

View File

@@ -2,11 +2,18 @@
position: relative;
}
/* The container the raw source is injected into. */
/* Fallback container used only for the empty, non-editor case. */
.htmlEmbedContent {
width: 100%;
}
/* The sandboxed iframe the embed source is rendered into. */
.htmlEmbedFrame {
display: block;
width: 100%;
border: none;
}
/* Edit affordance overlay, only shown while editing the document. */
.htmlEmbedToolbar {
position: absolute;

View File

@@ -1,85 +1,114 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import clsx from "clsx";
import {
ActionIcon,
Button,
Group,
Modal,
NumberInput,
Text,
Textarea,
} from "@mantine/core";
import { IconCode, IconEdit } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai";
import useUserRole from "@/hooks/use-user-role.tsx";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import classes from "./html-embed-view.module.css";
import {
buildSandboxSrcdoc,
canEdit as computeCanEdit,
renderRawHtml,
HTML_EMBED_HEIGHT_MESSAGE,
shouldExecute as computeShouldExecute,
} from "./render-raw-html.ts";
// Sane bounds for the auto-resized iframe so a runaway embed cannot blow up the
// page layout, and a sensible default before the first height message arrives.
const MIN_IFRAME_HEIGHT = 40;
const MAX_IFRAME_HEIGHT = 4000;
const DEFAULT_IFRAME_HEIGHT = 150;
export default function HtmlEmbedView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, selected, updateAttributes, editor } = props;
const { source } = node.attrs as { source: string };
const { isAdmin } = useUserRole();
const { source, height } = node.attrs as {
source: string;
height: number | null;
};
// Defense in depth: only execute the raw HTML/JS when the workspace HTML embed
// feature toggle is ON. When OFF (the default), we render a neutral disabled
// placeholder and inject nothing — so turning the feature off neutralizes
// existing embeds at render time as well as on the next server-side save.
// The HTML embed renders inside a SANDBOXED iframe (no same-origin access), so
// the workspace toggle is a feature switch, not a security gate. When OFF (the
// default) we render a neutral placeholder in the editor and nothing else.
const workspace = useAtomValue(workspaceAtom);
const htmlEmbedEnabled = workspace?.settings?.htmlEmbed === true;
// Execution policy split by editor mode:
// - READ-ONLY / public-share view: the SERVER already decided whether to
// include the embed (it strips htmlEmbed from shared content when the
// workspace toggle is OFF). An anonymous viewer has no workspace and thus
// reads `htmlEmbedEnabled` as false, so we must NOT gate execution on it
// here — we execute exactly the `source` the server chose to serve.
// - EDITABLE editor (admin authoring): keep gating on the per-workspace
// toggle so an admin sees the inert placeholder when the feature is OFF.
const shouldExecute = computeShouldExecute(
editor.isEditable,
htmlEmbedEnabled,
);
const contentRef = useRef<HTMLDivElement | null>(null);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [draft, setDraft] = useState<string>(source || "");
const [draftHeight, setDraftHeight] = useState<number | "">(height ?? "");
// (Re)render the raw source whenever it changes. This runs in BOTH the
// editable editor and the read-only / public-share editor (same NodeView),
// so trackers fire for readers too — that is the intended behaviour. When the
// feature toggle is OFF we clear the container and inject/execute nothing.
// Auto-resize height tracked in state (used only when no fixed height is set).
const [autoHeight, setAutoHeight] = useState<number>(
height ?? DEFAULT_IFRAME_HEIGHT,
);
const srcdoc = useMemo(() => buildSandboxSrcdoc(source || ""), [source]);
// Auto-resize: accept height messages ONLY from this iframe's own content
// window. The sandboxed srcdoc has an opaque ("null") origin, so we cannot
// match by event.origin — we match by event.source instead. No-op when a
// fixed height is configured.
useEffect(() => {
if (!contentRef.current) return;
if (shouldExecute) {
renderRawHtml(contentRef.current, source || "");
} else {
contentRef.current.innerHTML = "";
if (typeof height === "number") return;
function onMessage(event: MessageEvent) {
if (event.source !== iframeRef.current?.contentWindow) return;
const data = event.data as { type?: string; height?: number };
if (data?.type !== HTML_EMBED_HEIGHT_MESSAGE) return;
const next = Number(data.height);
if (!Number.isFinite(next)) return;
setAutoHeight(
Math.min(MAX_IFRAME_HEIGHT, Math.max(MIN_IFRAME_HEIGHT, next)),
);
}
}, [source, shouldExecute]);
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [height]);
const effectiveHeight =
typeof height === "number"
? Math.min(MAX_IFRAME_HEIGHT, Math.max(MIN_IFRAME_HEIGHT, height))
: autoHeight;
const openEditor = useCallback(() => {
setDraft(source || "");
setDraftHeight(height ?? "");
setModalOpen(true);
}, [source]);
}, [source, height]);
const onSave = useCallback(() => {
if (editor.isEditable) {
updateAttributes({ source: draft });
updateAttributes({
source: draft,
height: draftHeight === "" ? null : Number(draftHeight),
});
}
setModalOpen(false);
}, [draft, editor.isEditable, updateAttributes]);
}, [draft, draftHeight, editor.isEditable, updateAttributes]);
// The edit affordance is only meaningful in edit mode, is restricted to admins
// (the server strips the node for non-admins anyway), and is offered only when
// the workspace feature toggle is ON.
const canEdit = computeCanEdit(editor.isEditable, isAdmin, htmlEmbedEnabled);
// The edit affordance is only meaningful in edit mode and is offered only when
// the workspace master toggle is ON. Any member can edit (sandboxed = safe).
const canEdit = computeCanEdit(editor.isEditable, htmlEmbedEnabled);
return (
<NodeViewWrapper
@@ -103,10 +132,10 @@ export default function HtmlEmbedView(props: NodeViewProps) {
{!shouldExecute ? (
// Feature disabled for this workspace AND we're in the editable editor:
// never inject/execute the source. Show a neutral placeholder so an
// existing embed is visibly inert for the authoring admin. Read-only /
// share viewers never hit this branch (`shouldExecute` is always true
// there) — they execute exactly the source the server chose to serve.
// render a neutral placeholder so an existing embed is visibly inert for
// the author. Read-only / share viewers never hit this branch
// (`shouldExecute` is always true there) — they render exactly the
// source the server chose to serve.
<div className={classes.htmlEmbedPlaceholder}>
<IconCode size={18} />
<Text size="sm">
@@ -114,9 +143,18 @@ export default function HtmlEmbedView(props: NodeViewProps) {
</Text>
</div>
) : source ? (
// Raw HTML/CSS/JS rendered into the wiki origin. Scripts are re-created
// in renderRawHtml so they execute.
<div ref={contentRef} className={classes.htmlEmbedContent} />
// Raw HTML/CSS/JS rendered inside a sandboxed iframe (no same-origin):
// scripts run in an opaque origin and cannot touch the viewer's
// session/cookies/API.
<iframe
ref={iframeRef}
className={classes.htmlEmbedFrame}
sandbox="allow-scripts allow-popups allow-forms"
srcDoc={srcdoc}
title="HTML embed"
referrerPolicy="no-referrer"
style={{ width: "100%", border: "none", height: effectiveHeight }}
/>
) : canEdit ? (
<div className={classes.htmlEmbedPlaceholder} onClick={openEditor}>
<IconCode size={18} />
@@ -124,7 +162,7 @@ export default function HtmlEmbedView(props: NodeViewProps) {
</div>
) : (
// Empty source, non-editor: render nothing visible.
<div ref={contentRef} className={classes.htmlEmbedContent} />
<div className={classes.htmlEmbedContent} />
)}
<Modal
@@ -135,7 +173,7 @@ export default function HtmlEmbedView(props: NodeViewProps) {
>
<Text size="xs" c="dimmed" mb="xs">
{t(
"This HTML/CSS/JS runs in the page origin for everyone who views it. Admins only.",
"This HTML/CSS/JS runs in a sandboxed frame and cannot access the viewer's session, cookies, or API.",
)}
</Text>
<Textarea
@@ -148,6 +186,19 @@ export default function HtmlEmbedView(props: NodeViewProps) {
styles={{ input: { fontFamily: "monospace" } }}
data-autofocus
/>
<NumberInput
mt="md"
label={t("Height (px, blank = auto)")}
value={draftHeight}
onChange={(value) =>
setDraftHeight(
value === "" || value === null ? "" : Number(value),
)
}
min={MIN_IFRAME_HEIGHT}
max={MAX_IFRAME_HEIGHT}
allowDecimal={false}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={() => setModalOpen(false)}>
{t("Cancel")}

View File

@@ -1,112 +1,63 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { JSDOM } from "jsdom";
import { renderRawHtml, shouldExecute, canEdit } from "./render-raw-html";
import { describe, it, expect } from "vitest";
import {
buildSandboxSrcdoc,
canEdit,
HTML_EMBED_HEIGHT_MESSAGE,
shouldExecute,
} from "./render-raw-html";
// jsdom does NOT execute <script> nodes unless its instance was created with
// `runScripts: "dangerously"`. The whole point of renderRawHtml is to make
// re-created scripts run, so the execution tests drive a dedicated script-
// running JSDOM and pass it a container from THAT document (renderRawHtml uses
// `container.ownerDocument`, so it creates the fresh scripts in the running
// instance). The default vitest jsdom (no runScripts) is used for the
// structural and policy assertions.
describe("renderRawHtml (script execution against a runScripts jsdom)", () => {
let dom: JSDOM;
let container: HTMLElement;
beforeEach(() => {
dom = new JSDOM("<!doctype html><html><body></body></html>", {
runScripts: "dangerously",
});
container = dom.window.document.createElement("div");
dom.window.document.body.appendChild(container);
describe("buildSandboxSrcdoc", () => {
it("embeds the user source verbatim", () => {
const out = buildSandboxSrcdoc("<div id='x'>hello</div>");
expect(out).toContain("<div id='x'>hello</div>");
});
afterEach(() => {
dom.window.close();
it("injects the height-postMessage bootstrap after the source", () => {
const out = buildSandboxSrcdoc("<p>body</p>");
// The bootstrap is appended AFTER the source.
expect(out.indexOf("<p>body</p>")).toBeLessThan(
out.indexOf(HTML_EMBED_HEIGHT_MESSAGE),
);
// It reports its height to the parent via postMessage with the agreed type.
expect(out).toContain("parent.postMessage");
expect(out).toContain(HTML_EMBED_HEIGHT_MESSAGE);
// It observes resizes so the parent can keep the iframe sized to fit.
expect(out).toContain("ResizeObserver");
expect(out).toContain('addEventListener("load"');
});
it("re-creates and executes an inline <script> (observable side effect)", () => {
renderRawHtml(
container,
"<div>hello</div><script>window.__htmlEmbedFlag = true;</script>",
);
// The re-created inline script ran inside the jsdom window.
expect((dom.window as unknown as Record<string, unknown>).__htmlEmbedFlag).toBe(
true,
);
// The non-script markup is preserved.
expect(container.querySelector("div")?.textContent).toBe("hello");
});
it("copies src/async/defer onto a re-created external <script src>", () => {
renderRawHtml(
container,
'<script src="https://example.com/t.js" async defer></script>',
);
const script = container.querySelector("script");
expect(script).not.toBeNull();
expect(script?.getAttribute("src")).toBe("https://example.com/t.js");
expect(script?.hasAttribute("async")).toBe(true);
expect(script?.hasAttribute("defer")).toBe(true);
});
it("clears the container when the source is empty", () => {
container.innerHTML = "<p>stale</p>";
renderRawHtml(container, "");
expect(container.innerHTML).toBe("");
});
it("clears prior content first on a re-render with new source", () => {
const win = dom.window as unknown as Record<string, unknown>;
renderRawHtml(
container,
"<span id='first'>one</span><script>window.__htmlEmbedCount = 1;</script>",
);
expect(win.__htmlEmbedCount).toBe(1);
expect(container.querySelector("#first")).not.toBeNull();
renderRawHtml(
container,
"<span id='second'>two</span><script>window.__htmlEmbedCount = 2;</script>",
);
// Prior content is gone; only the new render remains.
expect(container.querySelector("#first")).toBeNull();
expect(container.querySelector("#second")).not.toBeNull();
expect(win.__htmlEmbedCount).toBe(2);
it("handles an empty source (still injects the bootstrap)", () => {
const out = buildSandboxSrcdoc("");
expect(out).toContain(HTML_EMBED_HEIGHT_MESSAGE);
});
});
describe("shouldExecute (execution policy)", () => {
it("read-only executes regardless of the workspace toggle", () => {
describe("shouldExecute (render policy)", () => {
it("read-only renders regardless of the workspace toggle", () => {
// isEditable=false → the server already gated the content.
expect(shouldExecute(false, false)).toBe(true);
expect(shouldExecute(false, true)).toBe(true);
});
it("editable + toggle OFF does NOT execute", () => {
it("editable + toggle OFF does NOT render", () => {
expect(shouldExecute(true, false)).toBe(false);
});
it("editable + toggle ON executes", () => {
it("editable + toggle ON renders", () => {
expect(shouldExecute(true, true)).toBe(true);
});
});
describe("canEdit (edit policy)", () => {
it("a member (non-admin) can never edit", () => {
expect(canEdit(true, false, true)).toBe(false);
expect(canEdit(false, false, true)).toBe(false);
it("any member can edit when editable and the toggle is ON (no admin gate)", () => {
expect(canEdit(true, true)).toBe(true);
});
it("an admin with the toggle OFF cannot edit", () => {
expect(canEdit(true, true, false)).toBe(false);
it("cannot edit when the toggle is OFF", () => {
expect(canEdit(true, false)).toBe(false);
});
it("an admin with the toggle ON in editable mode can edit", () => {
expect(canEdit(true, true, true)).toBe(true);
});
it("an admin in read-only mode cannot edit (no edit affordance)", () => {
expect(canEdit(false, true, true)).toBe(false);
it("cannot edit in read-only mode (no edit affordance)", () => {
expect(canEdit(false, true)).toBe(false);
});
});

View File

@@ -1,56 +1,64 @@
/**
* Pure DOM helpers for the HTML embed node view. Kept out of the React
* component so the script re-creation/execution mechanism and the execution/
* edit policy can be unit-tested against a bare jsdom container with no
* Tiptap/Mantine providers.
* Pure helpers for the HTML embed node view. Kept out of the React component so
* the sandbox srcdoc builder and the execution/edit policy can be unit-tested
* against a bare environment with no Tiptap/Mantine providers.
*/
/** postMessage type the sandboxed iframe uses to report its content height. */
export const HTML_EMBED_HEIGHT_MESSAGE = "gitmost-html-embed-height";
/**
* Inject raw HTML (including <script> tags) into `container`, executing any
* scripts.
* Build the `srcdoc` document for the sandboxed embed iframe.
*
* Setting `innerHTML` does NOT run inline or external <script> tags the browser
* parses that way: the HTML spec marks scripts inserted via innerHTML as
* "already started" so they never execute. To get the tracker/analytics
* use-case working we walk the freshly-parsed scripts and replace each with a
* brand-new <script> element copying its attributes and inline code. A
* programmatically created+inserted <script> DOES execute, so this restores
* normal script behaviour in the wiki origin (Variant C).
* The user's `source` is placed verbatim, then a small bootstrap <script> is
* appended at the end of the body. The iframe is rendered with a sandbox that
* does NOT include `allow-same-origin`, so this content runs in an opaque
* ("null") origin and cannot read the viewer's cookies/session/API — it is
* harmless. The bootstrap measures the document height and reports it to the
* parent via postMessage on load and whenever the content resizes, so the
* parent can size the iframe to fit (auto-resize mode).
*/
export function renderRawHtml(container: HTMLElement, source: string): void {
// Clear any previous render (re-render on source change).
container.innerHTML = "";
if (!source) return;
container.innerHTML = source;
// Use the container's own document so the helper works against any document
// (the live page or a standalone jsdom instance in tests), not just the
// ambient global `document`.
const doc = container.ownerDocument;
const scripts = Array.from(container.querySelectorAll("script"));
for (const oldScript of scripts) {
const newScript = doc.createElement("script");
// Copy every attribute (src, type, async, defer, data-*, etc.).
for (const attr of Array.from(oldScript.attributes)) {
newScript.setAttribute(attr.name, attr.value);
export function buildSandboxSrcdoc(source: string): string {
const bootstrap = `
<script>
(function () {
function reportHeight() {
var doc = document.documentElement;
var body = document.body;
var height = Math.max(
doc ? doc.scrollHeight : 0,
body ? body.scrollHeight : 0
);
parent.postMessage(
{ type: ${JSON.stringify(HTML_EMBED_HEIGHT_MESSAGE)}, height: height },
"*"
);
}
// Copy inline code.
newScript.text = oldScript.textContent ?? "";
// Replacing the node in place triggers execution.
oldScript.parentNode?.replaceChild(newScript, oldScript);
}
window.addEventListener("load", reportHeight);
// Report immediately too, in case load already fired.
reportHeight();
if (typeof ResizeObserver !== "undefined") {
try {
var ro = new ResizeObserver(reportHeight);
ro.observe(document.documentElement);
} catch (e) {
// ResizeObserver unavailable/failed: the load handler still reports once.
}
}
})();
</script>`;
return `${source || ""}${bootstrap}`;
}
/**
* Execution policy split by editor mode:
* - READ-ONLY / public-share view: the SERVER already decided whether to
* include the embed (it strips htmlEmbed from shared content when the
* workspace toggle is OFF). An anonymous viewer has no workspace and thus
* reads `featureEnabled` as false, so we must NOT gate execution on it here
* — we execute exactly the `source` the server chose to serve.
* - EDITABLE editor (admin authoring): keep gating on the per-workspace toggle
* so an admin sees the inert placeholder when the feature is OFF.
* workspace master toggle is OFF). An anonymous viewer has no workspace and
* thus reads `featureEnabled` as false, so we must NOT gate rendering on it
* here — we render exactly the `source` the server chose to serve.
* - EDITABLE editor: gate on the per-workspace master toggle so an author sees
* the inert placeholder when the feature is OFF.
*/
export function shouldExecute(
isEditable: boolean,
@@ -60,14 +68,11 @@ export function shouldExecute(
}
/**
* The edit affordance is only meaningful in edit mode, is restricted to admins
* (the server strips the node for non-admins anyway), and is offered only when
* the workspace feature toggle is ON.
* The edit affordance is only meaningful in edit mode and is offered only when
* the workspace master toggle is ON. The block renders in a sandboxed iframe
* (no same-origin access), so authoring is allowed to ANY member — there is no
* admin requirement.
*/
export function canEdit(
isEditable: boolean,
isAdmin: boolean,
featureEnabled: boolean,
): boolean {
return isEditable && isAdmin && featureEnabled;
export function canEdit(isEditable: boolean, featureEnabled: boolean): boolean {
return isEditable && featureEnabled;
}