From 81823fce1e1a497b557a3cdafedacba917a3a279 Mon Sep 17 00:00:00 2001 From: claude_code Date: Sun, 21 Jun 2026 02:48:41 +0300 Subject: [PATCH] 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 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 --- .../public/locales/en-US/translation.json | 17 +- .../html-embed/html-embed-view.module.css | 9 +- .../components/html-embed/html-embed-view.tsx | 139 ++++++--- .../html-embed/render-raw-html.test.ts | 121 +++----- .../components/html-embed/render-raw-html.ts | 103 ++++--- .../components/slash-menu/menu-items.ts | 32 +- .../editor/components/slash-menu/types.ts | 10 +- .../components/html-embed-settings.tsx | 20 +- .../settings/components/tracker-settings.tsx | 98 ++++++ .../workspace/types/workspace.types.ts | 10 +- .../settings/workspace/workspace-settings.tsx | 2 + .../collaboration.handler.html-embed.spec.ts | 120 -------- .../collaboration/collaboration.handler.ts | 32 +- .../persistence.extension.html-embed.spec.ts | 280 ------------------ .../extensions/persistence.extension.ts | 64 +--- .../html-embed-import-detect.spec.ts | 17 +- .../helpers/prosemirror/html-embed.spec.ts | 65 +--- .../helpers/prosemirror/html-embed.util.ts | 51 +--- apps/server/src/core/page/page.controller.ts | 3 - .../page-service-html-embed-identity.spec.ts | 102 ------- .../src/core/page/services/page.service.ts | 65 +--- .../spec/page-template-access.spec.ts | 4 - .../spec/page-template-lookup.spec.ts | 1 - .../transclusion-unsync-html-embed.spec.ts | 145 --------- .../page/transclusion/transclusion.service.ts | 26 -- .../src/core/share/share-html-embed.spec.ts | 14 +- .../src/core/share/share-seo.controller.ts | 16 +- apps/server/src/core/share/share.service.ts | 19 +- .../workspace/dto/update-workspace.dto.ts | 16 +- .../services/workspace-html-embed.spec.ts | 34 +++ .../workspace/services/workspace.service.ts | 17 ++ .../services/file-import-task.service.ts | 47 +-- .../import-html-embed-identity.spec.ts | 123 -------- .../import/services/import.service.ts | 33 +-- .../src/lib/html-embed/html-embed.ts | 14 +- 35 files changed, 482 insertions(+), 1387 deletions(-) create mode 100644 apps/client/src/features/workspace/components/settings/components/tracker-settings.tsx delete mode 100644 apps/server/src/collaboration/collaboration.handler.html-embed.spec.ts delete mode 100644 apps/server/src/collaboration/extensions/persistence.extension.html-embed.spec.ts delete mode 100644 apps/server/src/core/page/services/page-service-html-embed-identity.spec.ts delete mode 100644 apps/server/src/core/page/transclusion/spec/transclusion-unsync-html-embed.spec.ts delete mode 100644 apps/server/src/integrations/import/services/import-html-embed-identity.spec.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index c04fc72d..7d4dbc79 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1237,5 +1237,20 @@ "Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.": "Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.", "No roles configured": "No roles configured", "Delete role": "Delete role", - "Are you sure you want to delete this role?": "Are you sure you want to delete this role?" + "Are you sure you want to delete this role?": "Are you sure you want to delete this role?", + "HTML embed": "HTML embed", + "Edit HTML embed": "Edit HTML embed", + "HTML embed is disabled in this workspace": "HTML embed is disabled in this workspace", + "Click to add HTML / CSS / JS": "Click to add HTML / CSS / JS", + "This HTML/CSS/JS runs in a sandboxed frame and cannot access the viewer's session, cookies, or API.": "This HTML/CSS/JS runs in a sandboxed frame and cannot access the viewer's session, cookies, or API.", + "": "", + "Height (px, blank = auto)": "Height (px, blank = auto)", + "advanced": "advanced", + "Enable HTML embed": "Enable HTML embed", + "Allow members to insert raw HTML/CSS/JavaScript blocks. The block renders in a sandboxed frame and cannot access the viewer's session, cookies, or API. Off by default.": "Allow members to insert raw HTML/CSS/JavaScript blocks. The block renders in a sandboxed frame and cannot access the viewer's session, cookies, or API. Off by default.", + "When enabled, any member can insert an HTML embed block. The toggle just enables or disables the block type workspace-wide.": "When enabled, any member can insert an HTML embed block. The toggle just enables or disables the block type workspace-wide.", + "Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.": "Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.", + "Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.": "Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.", + "Analytics / tracker": "Analytics / tracker", + "Injected verbatim into the of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Injected verbatim into the of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only." } diff --git a/apps/client/src/features/editor/components/html-embed/html-embed-view.module.css b/apps/client/src/features/editor/components/html-embed/html-embed-view.module.css index 75304685..2ff32e3a 100644 --- a/apps/client/src/features/editor/components/html-embed/html-embed-view.module.css +++ b/apps/client/src/features/editor/components/html-embed/html-embed-view.module.css @@ -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; diff --git a/apps/client/src/features/editor/components/html-embed/html-embed-view.tsx b/apps/client/src/features/editor/components/html-embed/html-embed-view.tsx index 273fbaff..0e6633b1 100644 --- a/apps/client/src/features/editor/components/html-embed/html-embed-view.tsx +++ b/apps/client/src/features/editor/components/html-embed/html-embed-view.tsx @@ -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(null); + const iframeRef = useRef(null); const [modalOpen, setModalOpen] = useState(false); const [draft, setDraft] = useState(source || ""); + const [draftHeight, setDraftHeight] = useState(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( + 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 ( @@ -114,9 +143,18 @@ export default function HtmlEmbedView(props: NodeViewProps) { ) : source ? ( - // Raw HTML/CSS/JS rendered into the wiki origin. Scripts are re-created - // in renderRawHtml so they execute. -
+ // 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. +