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

@@ -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.", "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", "No roles configured": "No roles configured",
"Delete role": "Delete role", "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.",
"<script>...</script>": "<script>...</script>",
"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 <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only."
} }

View File

@@ -2,11 +2,18 @@
position: relative; position: relative;
} }
/* The container the raw source is injected into. */ /* Fallback container used only for the empty, non-editor case. */
.htmlEmbedContent { .htmlEmbedContent {
width: 100%; 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. */ /* Edit affordance overlay, only shown while editing the document. */
.htmlEmbedToolbar { .htmlEmbedToolbar {
position: absolute; position: absolute;

View File

@@ -1,85 +1,114 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; 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 clsx from "clsx";
import { import {
ActionIcon, ActionIcon,
Button, Button,
Group, Group,
Modal, Modal,
NumberInput,
Text, Text,
Textarea, Textarea,
} from "@mantine/core"; } from "@mantine/core";
import { IconCode, IconEdit } from "@tabler/icons-react"; import { IconCode, IconEdit } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import useUserRole from "@/hooks/use-user-role.tsx";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import classes from "./html-embed-view.module.css"; import classes from "./html-embed-view.module.css";
import { import {
buildSandboxSrcdoc,
canEdit as computeCanEdit, canEdit as computeCanEdit,
renderRawHtml, HTML_EMBED_HEIGHT_MESSAGE,
shouldExecute as computeShouldExecute, shouldExecute as computeShouldExecute,
} from "./render-raw-html.ts"; } 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) { export default function HtmlEmbedView(props: NodeViewProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { node, selected, updateAttributes, editor } = props; const { node, selected, updateAttributes, editor } = props;
const { source } = node.attrs as { source: string }; const { source, height } = node.attrs as {
const { isAdmin } = useUserRole(); source: string;
height: number | null;
};
// Defense in depth: only execute the raw HTML/JS when the workspace HTML embed // The HTML embed renders inside a SANDBOXED iframe (no same-origin access), so
// feature toggle is ON. When OFF (the default), we render a neutral disabled // the workspace toggle is a feature switch, not a security gate. When OFF (the
// placeholder and inject nothing — so turning the feature off neutralizes // default) we render a neutral placeholder in the editor and nothing else.
// existing embeds at render time as well as on the next server-side save.
const workspace = useAtomValue(workspaceAtom); const workspace = useAtomValue(workspaceAtom);
const htmlEmbedEnabled = workspace?.settings?.htmlEmbed === true; 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( const shouldExecute = computeShouldExecute(
editor.isEditable, editor.isEditable,
htmlEmbedEnabled, htmlEmbedEnabled,
); );
const contentRef = useRef<HTMLDivElement | null>(null); const iframeRef = useRef<HTMLIFrameElement | null>(null);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [draft, setDraft] = useState<string>(source || ""); 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 // Auto-resize height tracked in state (used only when no fixed height is set).
// editable editor and the read-only / public-share editor (same NodeView), const [autoHeight, setAutoHeight] = useState<number>(
// so trackers fire for readers too — that is the intended behaviour. When the height ?? DEFAULT_IFRAME_HEIGHT,
// feature toggle is OFF we clear the container and inject/execute nothing. );
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(() => { useEffect(() => {
if (!contentRef.current) return; if (typeof height === "number") return;
if (shouldExecute) { function onMessage(event: MessageEvent) {
renderRawHtml(contentRef.current, source || ""); if (event.source !== iframeRef.current?.contentWindow) return;
} else { const data = event.data as { type?: string; height?: number };
contentRef.current.innerHTML = ""; 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(() => { const openEditor = useCallback(() => {
setDraft(source || ""); setDraft(source || "");
setDraftHeight(height ?? "");
setModalOpen(true); setModalOpen(true);
}, [source]); }, [source, height]);
const onSave = useCallback(() => { const onSave = useCallback(() => {
if (editor.isEditable) { if (editor.isEditable) {
updateAttributes({ source: draft }); updateAttributes({
source: draft,
height: draftHeight === "" ? null : Number(draftHeight),
});
} }
setModalOpen(false); 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 edit affordance is only meaningful in edit mode and is offered only when
// (the server strips the node for non-admins anyway), and is offered only when // the workspace master toggle is ON. Any member can edit (sandboxed = safe).
// the workspace feature toggle is ON. const canEdit = computeCanEdit(editor.isEditable, htmlEmbedEnabled);
const canEdit = computeCanEdit(editor.isEditable, isAdmin, htmlEmbedEnabled);
return ( return (
<NodeViewWrapper <NodeViewWrapper
@@ -103,10 +132,10 @@ export default function HtmlEmbedView(props: NodeViewProps) {
{!shouldExecute ? ( {!shouldExecute ? (
// Feature disabled for this workspace AND we're in the editable editor: // Feature disabled for this workspace AND we're in the editable editor:
// never inject/execute the source. Show a neutral placeholder so an // render a neutral placeholder so an existing embed is visibly inert for
// existing embed is visibly inert for the authoring admin. Read-only / // the author. Read-only / share viewers never hit this branch
// share viewers never hit this branch (`shouldExecute` is always true // (`shouldExecute` is always true there) — they render exactly the
// there) — they execute exactly the source the server chose to serve. // source the server chose to serve.
<div className={classes.htmlEmbedPlaceholder}> <div className={classes.htmlEmbedPlaceholder}>
<IconCode size={18} /> <IconCode size={18} />
<Text size="sm"> <Text size="sm">
@@ -114,9 +143,18 @@ export default function HtmlEmbedView(props: NodeViewProps) {
</Text> </Text>
</div> </div>
) : source ? ( ) : source ? (
// Raw HTML/CSS/JS rendered into the wiki origin. Scripts are re-created // Raw HTML/CSS/JS rendered inside a sandboxed iframe (no same-origin):
// in renderRawHtml so they execute. // scripts run in an opaque origin and cannot touch the viewer's
<div ref={contentRef} className={classes.htmlEmbedContent} /> // 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 ? ( ) : canEdit ? (
<div className={classes.htmlEmbedPlaceholder} onClick={openEditor}> <div className={classes.htmlEmbedPlaceholder} onClick={openEditor}>
<IconCode size={18} /> <IconCode size={18} />
@@ -124,7 +162,7 @@ export default function HtmlEmbedView(props: NodeViewProps) {
</div> </div>
) : ( ) : (
// Empty source, non-editor: render nothing visible. // Empty source, non-editor: render nothing visible.
<div ref={contentRef} className={classes.htmlEmbedContent} /> <div className={classes.htmlEmbedContent} />
)} )}
<Modal <Modal
@@ -135,7 +173,7 @@ export default function HtmlEmbedView(props: NodeViewProps) {
> >
<Text size="xs" c="dimmed" mb="xs"> <Text size="xs" c="dimmed" mb="xs">
{t( {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> </Text>
<Textarea <Textarea
@@ -148,6 +186,19 @@ export default function HtmlEmbedView(props: NodeViewProps) {
styles={{ input: { fontFamily: "monospace" } }} styles={{ input: { fontFamily: "monospace" } }}
data-autofocus 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"> <Group justify="flex-end" mt="md">
<Button variant="default" onClick={() => setModalOpen(false)}> <Button variant="default" onClick={() => setModalOpen(false)}>
{t("Cancel")} {t("Cancel")}

View File

@@ -1,112 +1,63 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { describe, it, expect } from "vitest";
import { JSDOM } from "jsdom"; import {
import { renderRawHtml, shouldExecute, canEdit } from "./render-raw-html"; buildSandboxSrcdoc,
canEdit,
HTML_EMBED_HEIGHT_MESSAGE,
shouldExecute,
} from "./render-raw-html";
// jsdom does NOT execute <script> nodes unless its instance was created with describe("buildSandboxSrcdoc", () => {
// `runScripts: "dangerously"`. The whole point of renderRawHtml is to make it("embeds the user source verbatim", () => {
// re-created scripts run, so the execution tests drive a dedicated script- const out = buildSandboxSrcdoc("<div id='x'>hello</div>");
// running JSDOM and pass it a container from THAT document (renderRawHtml uses expect(out).toContain("<div id='x'>hello</div>");
// `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);
}); });
afterEach(() => { it("injects the height-postMessage bootstrap after the source", () => {
dom.window.close(); const out = buildSandboxSrcdoc("<p>body</p>");
}); // The bootstrap is appended AFTER the source.
expect(out.indexOf("<p>body</p>")).toBeLessThan(
it("re-creates and executes an inline <script> (observable side effect)", () => { out.indexOf(HTML_EMBED_HEIGHT_MESSAGE),
renderRawHtml(
container,
"<div>hello</div><script>window.__htmlEmbedFlag = true;</script>",
); );
// The re-created inline script ran inside the jsdom window. // It reports its height to the parent via postMessage with the agreed type.
expect((dom.window as unknown as Record<string, unknown>).__htmlEmbedFlag).toBe( expect(out).toContain("parent.postMessage");
true, expect(out).toContain(HTML_EMBED_HEIGHT_MESSAGE);
); // It observes resizes so the parent can keep the iframe sized to fit.
// The non-script markup is preserved. expect(out).toContain("ResizeObserver");
expect(container.querySelector("div")?.textContent).toBe("hello"); expect(out).toContain('addEventListener("load"');
}); });
it("copies src/async/defer onto a re-created external <script src>", () => { it("handles an empty source (still injects the bootstrap)", () => {
renderRawHtml( const out = buildSandboxSrcdoc("");
container, expect(out).toContain(HTML_EMBED_HEIGHT_MESSAGE);
'<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);
}); });
}); });
describe("shouldExecute (execution policy)", () => { describe("shouldExecute (render policy)", () => {
it("read-only executes regardless of the workspace toggle", () => { it("read-only renders regardless of the workspace toggle", () => {
// isEditable=false → the server already gated the content. // isEditable=false → the server already gated the content.
expect(shouldExecute(false, false)).toBe(true); expect(shouldExecute(false, false)).toBe(true);
expect(shouldExecute(false, true)).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); expect(shouldExecute(true, false)).toBe(false);
}); });
it("editable + toggle ON executes", () => { it("editable + toggle ON renders", () => {
expect(shouldExecute(true, true)).toBe(true); expect(shouldExecute(true, true)).toBe(true);
}); });
}); });
describe("canEdit (edit policy)", () => { describe("canEdit (edit policy)", () => {
it("a member (non-admin) can never edit", () => { it("any member can edit when editable and the toggle is ON (no admin gate)", () => {
expect(canEdit(true, false, true)).toBe(false); expect(canEdit(true, true)).toBe(true);
expect(canEdit(false, false, true)).toBe(false);
}); });
it("an admin with the toggle OFF cannot edit", () => { it("cannot edit when the toggle is OFF", () => {
expect(canEdit(true, true, false)).toBe(false); expect(canEdit(true, false)).toBe(false);
}); });
it("an admin with the toggle ON in editable mode can edit", () => { it("cannot edit in read-only mode (no edit affordance)", () => {
expect(canEdit(true, true, true)).toBe(true); expect(canEdit(false, true)).toBe(false);
});
it("an admin in read-only mode cannot edit (no edit affordance)", () => {
expect(canEdit(false, true, true)).toBe(false);
}); });
}); });

View File

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

View File

@@ -623,10 +623,9 @@ const CommandGroups: SlashMenuGroupedItemsType = {
}, },
{ {
title: "HTML embed", title: "HTML embed",
description: "Embed raw HTML, CSS and JavaScript (admins only).", description: "Embed raw HTML, CSS and JavaScript (sandboxed).",
searchTerms: ["html", "css", "js", "javascript", "script", "tracker", "analytics", "raw", "embed"], searchTerms: ["html", "css", "js", "javascript", "script", "tracker", "analytics", "raw", "embed"],
icon: IconCode, icon: IconCode,
adminOnly: true,
requiresHtmlEmbedFeature: true, requiresHtmlEmbedFeature: true,
command: ({ editor, range }: CommandProps) => { command: ({ editor, range }: CommandProps) => {
editor editor
@@ -795,30 +794,12 @@ const CommandGroups: SlashMenuGroupedItemsType = {
}; };
/** /**
* Read whether the current user is a workspace admin/owner from the persisted * Read the workspace-level HTML embed master toggle from the persisted
* `currentUser` (the same payload `currentUserAtom` stores via localStorage).
* Used to hide admin-only slash items (e.g. raw HTML embed). This is a UI gate
* only; the server independently strips admin-only nodes from non-admin writes.
*/
function isCurrentUserAdmin(): boolean {
try {
const raw = localStorage.getItem("currentUser");
if (!raw) return false;
const parsed = JSON.parse(raw);
const role = parsed?.user?.role;
return role === "owner" || role === "admin";
} catch {
return false;
}
}
/**
* Read the workspace-level HTML embed feature toggle from the persisted
* `currentUser` payload (the same localStorage entry `currentUserAtom` writes, * `currentUser` payload (the same localStorage entry `currentUserAtom` writes,
* carrying `workspace.settings`). ABSENT/false => OFF (the default). The slash * carrying `workspace.settings`). ABSENT/false => OFF (the default). The slash
* `getSuggestionItems` is a plain function (no React/atom context), so we read * `getSuggestionItems` is a plain function (no React/atom context), so we read
* the persisted state the same way `isCurrentUserAdmin()` does. UI gate only; * the persisted state directly. UI gate only; an anonymous public-share read is
* the server independently strips htmlEmbed from every non-allowed write. * served already-stripped content by the server when the toggle is OFF.
*/ */
function isHtmlEmbedFeatureEnabled(): boolean { function isHtmlEmbedFeatureEnabled(): boolean {
try { try {
@@ -840,7 +821,6 @@ export const getSuggestionItems = ({
}): SlashMenuGroupedItemsType => { }): SlashMenuGroupedItemsType => {
const search = query.toLowerCase(); const search = query.toLowerCase();
const filteredGroups: SlashMenuGroupedItemsType = {}; const filteredGroups: SlashMenuGroupedItemsType = {};
const isAdmin = isCurrentUserAdmin();
const htmlEmbedFeatureEnabled = isHtmlEmbedFeatureEnabled(); const htmlEmbedFeatureEnabled = isHtmlEmbedFeatureEnabled();
const fuzzyMatch = (query: string, target: string) => { const fuzzyMatch = (query: string, target: string) => {
@@ -856,9 +836,7 @@ export const getSuggestionItems = ({
for (const [group, items] of Object.entries(CommandGroups)) { for (const [group, items] of Object.entries(CommandGroups)) {
const filteredItems = items.filter((item) => { const filteredItems = items.filter((item) => {
if (excludeItems?.has(item.title)) return false; if (excludeItems?.has(item.title)) return false;
// Hide admin-only items (raw HTML embed) from non-admins. // Hide the HTML embed item unless the workspace master toggle is ON.
if (item.adminOnly && !isAdmin) return false;
// Hide HTML-embed-gated items unless the workspace feature toggle is ON.
if (item.requiresHtmlEmbedFeature && !htmlEmbedFeatureEnabled) if (item.requiresHtmlEmbedFeature && !htmlEmbedFeatureEnabled)
return false; return false;
return ( return (

View File

@@ -21,13 +21,9 @@ export type SlashMenuItemType = {
searchTerms: string[]; searchTerms: string[];
command: (props: CommandProps) => void; command: (props: CommandProps) => void;
disable?: (editor: ReturnType<typeof useEditor>) => boolean; disable?: (editor: ReturnType<typeof useEditor>) => boolean;
// When true, the item is only offered to workspace admins/owners. This is a // When true, the item is hidden unless the workspace HTML embed master toggle
// UI convenience only — the real authoring gate is enforced server-side. // is ON. UI gate only — for anonymous public-share reads the server serves
adminOnly?: boolean; // already-stripped content when the toggle is OFF.
// When true, the item is hidden unless the workspace HTML embed feature toggle
// is ON. Combined with adminOnly, the item shows only for admins in workspaces
// where the feature is enabled. UI gate only — the server strips htmlEmbed on
// every write where the toggle is OFF or the user is not an admin.
requiresHtmlEmbedFeature?: boolean; requiresHtmlEmbedFeature?: boolean;
}; };

View File

@@ -8,13 +8,13 @@ import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
/** /**
* Admin toggle for the workspace HTML embed feature. * Workspace master toggle that enables/disables the HTML embed block type.
* *
* SECURITY: when ON, workspace admins/owners can embed raw HTML/CSS/JS that * The block renders inside a SANDBOXED iframe (no same-origin access), so it
* EXECUTES in the wiki page origin for every reader (a deliberate stored-XSS * cannot touch the viewer's session/cookies/API — it is a feature switch, not a
* surface, e.g. for analytics trackers). OFF by default. The server strips * security gate. When ON, ANY member can insert the block. OFF by default; for
* htmlEmbed nodes on every write where the toggle is OFF or the saver is not an * anonymous public-share reads the server serves already-stripped content when
* admin, so this switch fully enables/disables the feature workspace-wide. * the toggle is OFF. The toggle itself is managed by workspace admins.
*/ */
export default function HtmlEmbedSettings() { export default function HtmlEmbedSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -69,7 +69,7 @@ export default function HtmlEmbedSettings() {
<Switch <Switch
label={t("Enable HTML embed")} label={t("Enable HTML embed")}
description={t( description={t(
"Allow workspace admins to insert raw HTML/CSS/JavaScript that EXECUTES in the wiki page origin for everyone who views the page (a deliberate stored-XSS surface, e.g. for analytics trackers). 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.",
)} )}
checked={checked} checked={checked}
disabled={!isAdmin || isLoading} disabled={!isAdmin || isLoading}
@@ -79,17 +79,17 @@ export default function HtmlEmbedSettings() {
<List size="xs" c="dimmed" mt="md" spacing={4}> <List size="xs" c="dimmed" mt="md" spacing={4}>
<List.Item> <List.Item>
{t( {t(
"Only workspace admins/owners can insert HTML embeds. Members never can: the editor option is hidden for them and the server strips the embed on save at every write path.", "When enabled, any member can insert an HTML embed block. The toggle just enables or disables the block type workspace-wide.",
)} )}
</List.Item> </List.Item>
<List.Item> <List.Item>
{t( {t(
"If a non-admin edits and saves a page that contains an admin's embed, that save strips the embed (fail-closed). An admin must re-add it.", "Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.",
)} )}
</List.Item> </List.Item>
<List.Item> <List.Item>
{t( {t(
"Turning this off strips existing embeds on their next save and immediately disables execution (existing embeds render as a disabled placeholder).", "Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.",
)} )}
</List.Item> </List.Item>
</List> </List>

View File

@@ -0,0 +1,98 @@
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useAtom } from "jotai";
import { useState } from "react";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import {
Button,
Group,
Paper,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
/**
* Admin-only analytics/tracker snippet for public share pages.
*
* The value is injected VERBATIM into the <head> of PUBLIC SHARE pages only,
* in the page's own (same-origin) context. It is the deliberate same-origin
* surface for analytics snippets (Google Analytics, Yandex.Metrika, etc.).
* Admin only — the workspace settings write is admin-gated server-side, and the
* Save button is disabled for non-admins.
*/
export default function TrackerSettings() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const { isAdmin } = useUserRole();
const [value, setValue] = useState<string>(
workspace?.settings?.trackerHead ?? "",
);
const [isLoading, setIsLoading] = useState(false);
async function handleSave() {
setIsLoading(true);
try {
const updated = await updateWorkspace({ trackerHead: value });
setWorkspace({
...updated,
settings: {
...updated.settings,
trackerHead: value,
},
});
notifications.show({ message: t("Updated successfully") });
} catch (err) {
console.log(err);
notifications.show({
message: t("Failed to update data"),
color: "red",
});
} finally {
setIsLoading(false);
}
}
return (
<Stack mt="sm">
<Group justify="space-between" align="center">
<Text fw={700} size="lg">
{t("Analytics / tracker")}
</Text>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{t("advanced")}
</Text>
</Group>
<Paper withBorder radius="md" p="lg">
<Text size="xs" c="dimmed" mb="xs">
{t(
"Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.",
)}
</Text>
<Textarea
autosize
minRows={6}
maxRows={20}
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
placeholder={t("<script>...</script>")}
styles={{ input: { fontFamily: "monospace" } }}
disabled={!isAdmin || isLoading}
/>
<Group justify="flex-end" mt="md">
<Button
onClick={handleSave}
loading={isLoading}
disabled={!isAdmin}
>
{t("Save")}
</Button>
</Group>
</Paper>
</Stack>
);
}

View File

@@ -33,6 +33,9 @@ export interface IWorkspace {
// Write-only field for updateWorkspace({ htmlEmbed }). Read state lives at // Write-only field for updateWorkspace({ htmlEmbed }). Read state lives at
// settings.htmlEmbed. // settings.htmlEmbed.
htmlEmbed?: boolean; htmlEmbed?: boolean;
// Write-only field for updateWorkspace({ trackerHead }). Read state lives at
// settings.trackerHead.
trackerHead?: string;
} }
export interface IWorkspaceSettings { export interface IWorkspaceSettings {
@@ -40,8 +43,13 @@ export interface IWorkspaceSettings {
sharing?: IWorkspaceSharingSettings; sharing?: IWorkspaceSharingSettings;
api?: IWorkspaceApiSettings; api?: IWorkspaceApiSettings;
templates?: IWorkspaceTemplateSettings; templates?: IWorkspaceTemplateSettings;
// Admin-only HTML embed feature toggle. ABSENT/false => OFF (default). // HTML embed master toggle (enables/disables the block type). The block
// renders in a sandboxed iframe, so this is a feature switch, not a security
// gate. ABSENT/false => OFF (default).
htmlEmbed?: boolean; htmlEmbed?: boolean;
// Admin-only analytics/tracker snippet injected into the <head> of public
// share pages (same-origin). ABSENT/empty => none.
trackerHead?: string;
} }
export interface IWorkspaceApiSettings { export interface IWorkspaceApiSettings {

View File

@@ -2,6 +2,7 @@ import SettingsTitle from "@/components/settings/settings-title.tsx";
import WorkspaceNameForm from "@/features/workspace/components/settings/components/workspace-name-form"; import WorkspaceNameForm from "@/features/workspace/components/settings/components/workspace-name-form";
import WorkspaceIcon from "@/features/workspace/components/settings/components/workspace-icon.tsx"; import WorkspaceIcon from "@/features/workspace/components/settings/components/workspace-icon.tsx";
import HtmlEmbedSettings from "@/features/workspace/components/settings/components/html-embed-settings.tsx"; import HtmlEmbedSettings from "@/features/workspace/components/settings/components/html-embed-settings.tsx";
import TrackerSettings from "@/features/workspace/components/settings/components/tracker-settings.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getAppName } from "@/lib/config.ts"; import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async"; import { Helmet } from "react-helmet-async";
@@ -17,6 +18,7 @@ export default function WorkspaceSettings() {
<WorkspaceIcon /> <WorkspaceIcon />
<WorkspaceNameForm /> <WorkspaceNameForm />
<HtmlEmbedSettings /> <HtmlEmbedSettings />
<TrackerSettings />
</> </>
); );
} }

View File

@@ -1,120 +0,0 @@
import * as Y from 'yjs';
import { TiptapTransformer } from '@hocuspocus/transformer';
import { CollaborationHandler } from './collaboration.handler';
import { hasHtmlEmbedNode } from '../common/helpers/prosemirror/html-embed.util';
// Exercises the REAL CollaborationHandler.updatePageContent admin gate (the
// REST/MCP/AI content-update entrypoint, used by the page update endpoint and
// the MCP/AI agent). updatePageContent reads `user?.role` and strips htmlEmbed
// BEFORE handing the json to withYdocConnection. We stub only
// withYdocConnection (which would otherwise open a real hocuspocus connection):
// the role-extraction (`user?.role`) + strip that run upstream of it are REAL
// production code. The 'replace' branch then runs the production
// TiptapTransformer.toYdoc on the gated json against a real Y.Doc, which we
// decode back to JSON and assert on. This replaces the re-implemented
// `applyAdminGate` stand-in for this entrypoint.
const docWithEmbed = () => ({
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'keep' }] },
{
type: 'columns',
content: [
{
type: 'column',
attrs: { position: 'left' },
content: [
{ type: 'htmlEmbed', attrs: { source: '<script>nested</script>' } },
{ type: 'paragraph', content: [{ type: 'text', text: 'inner' }] },
],
},
{
type: 'column',
attrs: { position: 'right' },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'r' }] },
],
},
],
},
{ type: 'htmlEmbed', attrs: { source: '<script>top</script>' } },
],
});
/**
* Run the REAL updatePageContent('replace') with a stubbed withYdocConnection.
* The stub provides a real Y.Doc + recording fragment; the production fn calls
* TiptapTransformer.toYdoc(<gated json>) and applies it to the doc, so decoding
* the doc afterward yields exactly the gated content.
*/
async function gatedContentFor(
role: string | null | undefined,
featureEnabled = true,
) {
// Workspace settings read used by the toggle-AND-admin gate.
const workspaceRepo = {
findById: jest.fn(async () => ({
id: 'ws-1',
settings: { htmlEmbed: featureEnabled },
})),
};
const handler = new CollaborationHandler(workspaceRepo as any);
const captureDoc = new Y.Doc();
jest
.spyOn(handler, 'withYdocConnection')
.mockImplementation(async (_hp, _name, _ctx, fn: any) => {
const fragment = captureDoc.getXmlFragment('default');
// Mirror the real Document surface the fn touches.
const docLike: any = {
getXmlFragment: () => fragment,
};
// The fn does: fragment.delete(0,len) then
// Y.applyUpdate(doc, encodeStateAsUpdate(toYdoc(gatedJson))). It calls
// Y.applyUpdate(doc, ...) — so docLike must be a real Y.Doc target.
fn(captureDoc);
});
const handlers = handler.getHandlers({} as any);
await handlers.updatePageContent('page-1', {
prosemirrorJson: docWithEmbed(),
operation: 'replace',
user: { id: 'u1', role, workspaceId: 'ws-1' } as any,
});
return TiptapTransformer.fromYdoc(captureDoc, 'default');
}
describe('CollaborationHandler.updatePageContent htmlEmbed admin gate (real code)', () => {
it('non-admin (member): every htmlEmbed (top-level + nested) stripped before the ydoc', async () => {
const gated = await gatedContentFor('member');
expect(hasHtmlEmbedNode(gated)).toBe(false);
// Non-embed siblings survive.
const json = JSON.stringify(gated);
expect(json).toContain('keep');
expect(json).toContain('inner');
});
it('unknown/empty role: fails closed (stripped)', async () => {
for (const role of [undefined, null, 'viewer'] as const) {
expect(hasHtmlEmbedNode(await gatedContentFor(role))).toBe(false);
}
});
it('toggle ON + admin: htmlEmbed preserved', async () => {
expect(hasHtmlEmbedNode(await gatedContentFor('admin', true))).toBe(true);
});
it('toggle ON + owner: htmlEmbed preserved', async () => {
expect(hasHtmlEmbedNode(await gatedContentFor('owner', true))).toBe(true);
});
it('toggle OFF + admin: stripped (feature disabled for everyone)', async () => {
expect(hasHtmlEmbedNode(await gatedContentFor('admin', false))).toBe(false);
});
it('toggle OFF + member: stripped', async () => {
expect(hasHtmlEmbedNode(await gatedContentFor('member', false))).toBe(false);
});
});

View File

@@ -8,13 +8,6 @@ import {
import { setYjsMark, updateYjsMarkAttribute, YjsSelection } from './yjs.util'; import { setYjsMark, updateYjsMarkAttribute, YjsSelection } from './yjs.util';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { User } from '@docmost/db/types/entity.types'; import { User } from '@docmost/db/types/entity.types';
import {
hasHtmlEmbedNode,
htmlEmbedAllowed,
isHtmlEmbedFeatureEnabled,
stripHtmlEmbedNodes,
} from '../common/helpers/prosemirror/html-embed.util';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
export type CollabEventHandlers = ReturnType< export type CollabEventHandlers = ReturnType<
CollaborationHandler['getHandlers'] CollaborationHandler['getHandlers']
@@ -24,8 +17,6 @@ export type CollabEventHandlers = ReturnType<
export class CollaborationHandler { export class CollaborationHandler {
private readonly logger = new Logger(CollaborationHandler.name); private readonly logger = new Logger(CollaborationHandler.name);
constructor(private readonly workspaceRepo: WorkspaceRepo) {}
getHandlers(hocuspocus: Hocuspocus) { getHandlers(hocuspocus: Hocuspocus) {
return { return {
alterState: async (documentName: string, payload: { pageId: string }) => { alterState: async (documentName: string, payload: { pageId: string }) => {
@@ -91,30 +82,9 @@ export class CollaborationHandler {
}, },
) => { ) => {
const { operation, user } = payload; const { operation, user } = payload;
let { prosemirrorJson } = payload; const { prosemirrorJson } = payload;
this.logger.debug('Updating page content via yjs', documentName); this.logger.debug('Updating page content via yjs', documentName);
// SECURITY (Variant C admin gate, REST/MCP/AI write path):
// updatePageContent is the server-side entrypoint used by the REST page
// update endpoint and by the MCP/AI agent. Raw `htmlEmbed` nodes execute
// arbitrary JS in every reader's browser, so a NON-admin caller must not
// be able to persist them here. If the editing user is not a workspace
// admin/owner, strip every htmlEmbed node before it reaches the ydoc.
// Toggle-AND-admin gate: htmlEmbed survives only when the workspace
// feature toggle is ON and the editing user is an admin/owner. OFF
// (default) => stripped for everyone.
const htmlEmbedEnabled = isHtmlEmbedFeatureEnabled(
(await this.workspaceRepo.findById(user?.workspaceId))?.settings,
);
if (!htmlEmbedAllowed(htmlEmbedEnabled, user?.role)) {
if (hasHtmlEmbedNode(prosemirrorJson)) {
this.logger.warn(
`Stripping htmlEmbed node(s) from update by user ${user?.id} on ${documentName}`,
);
prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson);
}
}
await this.withYdocConnection( await this.withYdocConnection(
hocuspocus, hocuspocus,
documentName, documentName,

View File

@@ -1,280 +0,0 @@
import * as Y from 'yjs';
import { TiptapTransformer } from '@hocuspocus/transformer';
import { PersistenceExtension } from './persistence.extension';
import { tiptapExtensions } from '../collaboration.util';
import {
hasHtmlEmbedNode,
HTML_EMBED_NODE_NAME,
} from '../../common/helpers/prosemirror/html-embed.util';
// Exercises the REAL PersistenceExtension.onStoreDocument (the primary collab
// WebSocket write path) against a REAL ydoc, with thin repo/db/queue mocks.
// This replaces the prior re-implemented `applyAdminGate` stand-in for this
// entrypoint: if the role-extraction expression (`context?.user?.role`), the
// strip call, or the ydoc-rebuild branch is deleted/changed, these tests fail.
const RICH_DOC = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'intro paragraph' }],
},
{
type: 'columns',
content: [
{
type: 'column',
attrs: { position: 'left' },
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'left col, mentioning ' },
{
type: 'mention',
attrs: {
id: 'mention-1',
label: 'Alice',
entityType: 'user',
entityId: 'user-123',
creatorId: 'creator-1',
},
},
],
},
// Nested embed inside a column — must be stripped recursively.
{
type: HTML_EMBED_NODE_NAME,
attrs: { source: '<script>nested()</script>' },
},
],
},
{
type: 'column',
attrs: { position: 'right' },
content: [
{
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableHeader',
attrs: { colspan: 1, rowspan: 1 },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'H' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: { colspan: 1, rowspan: 1 },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'cell' }] },
],
},
],
},
],
},
],
},
],
},
// Top-level embed — must be stripped.
{
type: HTML_EMBED_NODE_NAME,
attrs: { source: '<script>top()</script>' },
},
{
type: 'paragraph',
content: [{ type: 'text', text: 'outro paragraph' }],
},
],
};
function buildYdoc(json: any): Y.Doc {
return TiptapTransformer.toYdoc(json, 'default', tiptapExtensions);
}
// Count nodes by type across the whole tree (excludes htmlEmbed by listing it
// separately) so we can assert every OTHER node type survived the strip.
function nodeTypeCounts(json: any): Record<string, number> {
const counts: Record<string, number> = {};
const walk = (n: any) => {
if (!n || typeof n !== 'object') return;
if (n.type) counts[n.type] = (counts[n.type] ?? 0) + 1;
if (Array.isArray(n.content)) n.content.forEach(walk);
};
walk(json);
return counts;
}
/**
* Construct a real PersistenceExtension with the minimum mocks needed for
* onStoreDocument to reach the strip + persist branch, and capture the content
* that would be written to the page row.
*/
function buildExtension(featureEnabled = true) {
const captured: { content?: any } = {};
const existingPage = {
id: 'page-1',
slugId: 'slug-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
creatorId: 'creator-1',
contributorIds: [],
content: { type: 'doc', content: [] }, // differs from new content -> persist runs
createdAt: new Date(),
lastUpdatedSource: 'user',
};
const pageRepo = {
findById: jest.fn(async () => ({ ...existingPage })),
updatePage: jest.fn(async (values: any) => {
captured.content = values.content;
}),
};
const pageHistoryRepo = {
findPageLastHistory: jest.fn(async () => null),
saveHistory: jest.fn(async () => undefined),
};
// db.transaction().execute(cb) just runs the callback (no real DB).
const db = {
transaction: () => ({
execute: (cb: any) => cb({} as any),
}),
};
const noopQueue = { add: jest.fn(async () => undefined) } as any;
const collabHistory = { addContributors: jest.fn(async () => undefined) } as any;
const transclusionService = {
syncPageTransclusions: jest.fn(async () => undefined),
syncPageReferences: jest.fn(async () => undefined),
} as any;
// Workspace settings read used by the toggle-AND-admin gate.
const workspaceRepo = {
findById: jest.fn(async () => ({
id: 'ws-1',
settings: { htmlEmbed: featureEnabled },
})),
};
const ext = new PersistenceExtension(
pageRepo as any,
pageHistoryRepo as any,
db as any,
noopQueue,
noopQueue,
noopQueue,
collabHistory,
transclusionService,
workspaceRepo as any,
);
return { ext, captured, pageRepo };
}
async function runStore(
role: string | null | undefined,
doc: Y.Doc,
featureEnabled = true,
) {
const { ext, captured } = buildExtension(featureEnabled);
// hocuspocus augments the Y.Doc with broadcastStateless; a bare Y.Doc has
// none, so stub it (the post-persist broadcast is not under test here).
(doc as any).broadcastStateless = () => undefined;
await ext.onStoreDocument({
documentName: 'page-1',
document: doc,
context: { user: { id: 'u1', role } },
} as any);
return captured;
}
describe('PersistenceExtension.onStoreDocument htmlEmbed admin gate (real code)', () => {
it('non-admin store: strips EVERY htmlEmbed but preserves every other node', async () => {
const doc = buildYdoc(RICH_DOC);
const before = TiptapTransformer.fromYdoc(doc, 'default');
expect(hasHtmlEmbedNode(before)).toBe(true);
const beforeCounts = nodeTypeCounts(before);
const captured = await runStore('member', doc);
expect(captured.content).toBeDefined();
// htmlEmbed gone from the persisted content.
expect(hasHtmlEmbedNode(captured.content)).toBe(false);
// Every non-embed node type is preserved with the SAME count (guards against
// data loss if a node were missing from tiptapExtensions and dropped on the
// toYdoc rebuild).
const afterCounts = nodeTypeCounts(captured.content);
for (const [type, count] of Object.entries(beforeCounts)) {
if (type === HTML_EMBED_NODE_NAME) continue;
expect(afterCounts[type]).toBe(count);
}
// The two embeds are gone.
expect(beforeCounts[HTML_EMBED_NODE_NAME]).toBe(2);
expect(afterCounts[HTML_EMBED_NODE_NAME]).toBeUndefined();
// The shared ydoc fragment was also rewritten clean (re-decode it).
const reDecoded = TiptapTransformer.fromYdoc(doc, 'default');
expect(hasHtmlEmbedNode(reDecoded)).toBe(false);
});
it('toggle ON + admin store: htmlEmbed preserved in persisted content', async () => {
const captured = await runStore('admin', buildYdoc(RICH_DOC), true);
expect(captured.content).toBeDefined();
expect(hasHtmlEmbedNode(captured.content)).toBe(true);
expect(nodeTypeCounts(captured.content)[HTML_EMBED_NODE_NAME]).toBe(2);
});
it('toggle ON + owner store: htmlEmbed preserved', async () => {
const captured = await runStore('owner', buildYdoc(RICH_DOC), true);
expect(hasHtmlEmbedNode(captured.content)).toBe(true);
});
it('toggle OFF + admin store: stripped (feature disabled for everyone)', async () => {
const captured = await runStore('admin', buildYdoc(RICH_DOC), false);
expect(hasHtmlEmbedNode(captured.content)).toBe(false);
});
it('toggle OFF + owner store: stripped', async () => {
const captured = await runStore('owner', buildYdoc(RICH_DOC), false);
expect(hasHtmlEmbedNode(captured.content)).toBe(false);
});
it('toggle OFF + member store: stripped', async () => {
const captured = await runStore('member', buildYdoc(RICH_DOC), false);
expect(hasHtmlEmbedNode(captured.content)).toBe(false);
});
it('unknown/empty role: fails closed (stripped)', async () => {
expect(
hasHtmlEmbedNode((await runStore(undefined, buildYdoc(RICH_DOC))).content),
).toBe(false);
expect(
hasHtmlEmbedNode((await runStore(null, buildYdoc(RICH_DOC))).content),
).toBe(false);
expect(
hasHtmlEmbedNode((await runStore('viewer', buildYdoc(RICH_DOC))).content),
).toBe(false);
});
it('empty-fragment ydoc (no content) does not throw and persists no embed', async () => {
const emptyDoc = buildYdoc({
type: 'doc',
content: [{ type: 'paragraph' }],
});
// Non-admin path with an empty/embed-free fragment must be a no-op strip,
// not throw.
await expect(runStore('member', emptyDoc)).resolves.toBeDefined();
});
});

View File

@@ -39,13 +39,6 @@ import {
HISTORY_INTERVAL, HISTORY_INTERVAL,
} from '../constants'; } from '../constants';
import { TransclusionService } from '../../core/page/transclusion/transclusion.service'; import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
import {
hasHtmlEmbedNode,
htmlEmbedAllowed,
isHtmlEmbedFeatureEnabled,
stripHtmlEmbedNodes,
} from '../../common/helpers/prosemirror/html-embed.util';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
@Injectable() @Injectable()
export class PersistenceExtension implements Extension { export class PersistenceExtension implements Extension {
@@ -66,7 +59,6 @@ export class PersistenceExtension implements Extension {
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue, @InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
private readonly collabHistory: CollabHistoryService, private readonly collabHistory: CollabHistoryService,
private readonly transclusionService: TransclusionService, private readonly transclusionService: TransclusionService,
private readonly workspaceRepo: WorkspaceRepo,
) {} ) {}
async onLoadDocument(data: onLoadDocumentPayload) { async onLoadDocument(data: onLoadDocumentPayload) {
@@ -120,61 +112,7 @@ export class PersistenceExtension implements Extension {
const pageId = getPageId(documentName); const pageId = getPageId(documentName);
let tiptapJson = TiptapTransformer.fromYdoc(document, 'default'); const tiptapJson = TiptapTransformer.fromYdoc(document, 'default');
// SECURITY (Variant C admin gate, collab WebSocket write path):
// The persisted snapshot is the merged ydoc, which may contain an htmlEmbed
// node inserted by ANY connected editor. htmlEmbed renders raw, unsanitized
// JS in every reader's browser, so only workspace admins/owners may author
// it. When the user whose store triggers this persist is not an admin, strip
// every htmlEmbed node before it is written to the page row AND before the
// ydoc state is re-encoded, so the node cannot be reintroduced by a
// non-admin via the collab socket.
// NOTE (residual risk): the gate is keyed to the storing connection's user.
// If an admin already authored an htmlEmbed and a non-admin's later store
// does not touch it, this strip would remove the admin's embed on that
// non-admin store. This is intentionally conservative (fail closed): the
// admin re-adds/keeps the node on their own next edit. A future refinement
// could diff against the previously persisted admin-authored embeds.
//
// ACCEPTED RESIDUAL RISK (pre-persist broadcast window): this strip runs in
// the debounced onStoreDocument, but hocuspocus broadcasts each inbound Yjs
// update to connected clients immediately, so a non-admin's transient
// htmlEmbed can execute in OTHER open editors' browsers in the brief window
// before this persist strips it. The exposure is limited to concurrent
// AUTHENTICATED space members who have the doc open with Edit rights
// (semi-trusted) — anonymous public-share/readonly viewers do NOT open a
// collab socket (ReadonlyPageEditor renders fetched, already-stripped
// content; HocuspocusProvider is only used by the authenticated editable
// page-editor), and the PERSISTED page row plus every share/readonly read
// path are protected by this strip. The window is therefore accepted rather
// than mitigated with an inbound beforeBroadcast strip.
// Toggle-AND-admin gate: htmlEmbed survives only when the workspace feature
// toggle is ON and the storing user is an admin/owner. OFF (default) =>
// stripped for everyone (existing embeds get cleaned up on next save).
const htmlEmbedEnabled = isHtmlEmbedFeatureEnabled(
(await this.workspaceRepo.findById(context?.user?.workspaceId))?.settings,
);
if (!htmlEmbedAllowed(htmlEmbedEnabled, context?.user?.role)) {
if (hasHtmlEmbedNode(tiptapJson)) {
this.logger.warn(
`Stripping htmlEmbed node(s) from collab store by user ${context?.user?.id} on ${documentName}`,
);
tiptapJson = stripHtmlEmbedNodes(tiptapJson);
// Reflect the stripped content back into the shared ydoc so the node is
// removed for all connected clients, not just the persisted row.
const fragment = document.getXmlFragment('default');
if (fragment.length > 0) {
fragment.delete(0, fragment.length);
}
const cleanDoc = TiptapTransformer.toYdoc(
tiptapJson,
'default',
tiptapExtensions,
);
Y.applyUpdate(document, Y.encodeStateAsUpdate(cleanDoc));
}
}
const ydocState = Buffer.from(Y.encodeStateAsUpdate(document)); const ydocState = Buffer.from(Y.encodeStateAsUpdate(document));

View File

@@ -3,20 +3,17 @@ import { htmlToJson } from '../../../collaboration/collaboration.util';
import { hasHtmlEmbedNode, stripHtmlEmbedNodes } from './html-embed.util'; import { hasHtmlEmbedNode, stripHtmlEmbedNodes } from './html-embed.util';
/** /**
* CONTRACT (security): an attacker who controls imported markdown/HTML could try * CONTRACT: imported markdown/HTML can carry an htmlEmbed in the *serialized*
* to smuggle an htmlEmbed in the *serialized* DOM form — * DOM form —
* <div data-type="htmlEmbed" data-source="..."> * <div data-type="htmlEmbed" data-source="...">
* — directly, bypassing the editor's `<!--html-embed:-->` comment marker. * — directly, bypassing the editor's `<!--html-embed:-->` comment marker.
* *
* This exercises the REAL server import conversion path that ImportService uses * The block renders inside a sandboxed iframe, so this is not an XSS surface;
* this exercises the REAL server import conversion path that ImportService uses
* (`markdownToHtml` then `htmlToJson`; `processHTML` adds only a cheerio * (`markdownToHtml` then `htmlToJson`; `processHTML` adds only a cheerio
* link/iframe normalize pass which does not touch htmlEmbed divs) and asserts * link/iframe normalize pass which does not touch htmlEmbed divs) and asserts
* the ACTUAL behaviour so we know whether the strip gate can be bypassed. * that such a node is DETECTED and STRIPPABLE — so the share read path's
* * master-toggle strip can remove it when the workspace toggle is OFF.
* FINDING (documented): the raw embed div DOES round-trip through marked +
* htmlToJson into a real `htmlEmbed` node, so `hasHtmlEmbedNode` returns true and
* `stripHtmlEmbedNodes` removes it. The serialized-form bypass is therefore
* detectable and STRIPPABLE — the write-path gate covers it.
*/ */
describe('htmlEmbed smuggled via the raw serialized div in imported markdown/HTML', () => { describe('htmlEmbed smuggled via the raw serialized div in imported markdown/HTML', () => {
it('round-trips through markdownToHtml -> htmlToJson and is DETECTED (base64 data-source)', async () => { it('round-trips through markdownToHtml -> htmlToJson and is DETECTED (base64 data-source)', async () => {
@@ -38,7 +35,7 @@ describe('htmlEmbed smuggled via the raw serialized div in imported markdown/HTM
// The div parses into a real htmlEmbed node carrying the decoded source. // The div parses into a real htmlEmbed node carrying the decoded source.
expect(hasHtmlEmbedNode(json)).toBe(true); expect(hasHtmlEmbedNode(json)).toBe(true);
// Because it is detected, the write-path gate can strip it for non-admins. // Because it is detected, the share master-toggle strip can remove it.
const stripped = stripHtmlEmbedNodes(json); const stripped = stripHtmlEmbedNodes(json);
expect(hasHtmlEmbedNode(stripped)).toBe(false); expect(hasHtmlEmbedNode(stripped)).toBe(false);
// Surrounding non-embed content is retained. // Surrounding non-embed content is retained.

View File

@@ -1,7 +1,5 @@
import { import {
canAuthorHtmlEmbed,
hasHtmlEmbedNode, hasHtmlEmbedNode,
htmlEmbedAllowed,
isHtmlEmbedFeatureEnabled, isHtmlEmbedFeatureEnabled,
stripHtmlEmbedNodes, stripHtmlEmbedNodes,
} from './html-embed.util'; } from './html-embed.util';
@@ -190,19 +188,6 @@ describe('hasHtmlEmbedNode (root/odd-shape detection)', () => {
}); });
}); });
describe('canAuthorHtmlEmbed', () => {
it('allows owner and admin', () => {
expect(canAuthorHtmlEmbed('owner')).toBe(true);
expect(canAuthorHtmlEmbed('admin')).toBe(true);
});
it('denies member and unknown/empty roles', () => {
expect(canAuthorHtmlEmbed('member')).toBe(false);
expect(canAuthorHtmlEmbed(null)).toBe(false);
expect(canAuthorHtmlEmbed(undefined)).toBe(false);
expect(canAuthorHtmlEmbed('viewer')).toBe(false);
});
});
describe('isHtmlEmbedFeatureEnabled', () => { describe('isHtmlEmbedFeatureEnabled', () => {
it('is true only when settings.htmlEmbed === true', () => { it('is true only when settings.htmlEmbed === true', () => {
expect(isHtmlEmbedFeatureEnabled({ htmlEmbed: true })).toBe(true); expect(isHtmlEmbedFeatureEnabled({ htmlEmbed: true })).toBe(true);
@@ -217,52 +202,22 @@ describe('isHtmlEmbedFeatureEnabled', () => {
}); });
}); });
describe('htmlEmbedAllowed (toggle AND admin)', () => { // The htmlEmbed node renders inside a sandboxed iframe, so the per-write role
it('toggle OFF + admin/owner => not allowed (feature disabled for everyone)', () => { // gate has been removed. `stripHtmlEmbedNodes` + `isHtmlEmbedFeatureEnabled`
expect(htmlEmbedAllowed(false, 'admin')).toBe(false); // remain ONLY to honor the workspace master toggle on the anonymous public-share
expect(htmlEmbedAllowed(false, 'owner')).toBe(false); // read path — tested against the real share code in:
}); // - core/share/share-html-embed.spec.ts
it('toggle OFF + member => not allowed', () => {
expect(htmlEmbedAllowed(false, 'member')).toBe(false);
});
it('toggle ON + admin/owner => allowed', () => {
expect(htmlEmbedAllowed(true, 'admin')).toBe(true);
expect(htmlEmbedAllowed(true, 'owner')).toBe(true);
});
it('toggle ON + member/unknown => not allowed', () => {
expect(htmlEmbedAllowed(true, 'member')).toBe(false);
expect(htmlEmbedAllowed(true, null)).toBe(false);
expect(htmlEmbedAllowed(true, undefined)).toBe(false);
expect(htmlEmbedAllowed(true, 'viewer')).toBe(false);
});
});
// NOTE: a previous revision of this file re-implemented the write-path admin
// gate as a local `applyAdminGate` stand-in and asserted against THAT. A
// deleted/misplaced real guard would have kept those green. The stand-in is
// removed. The collab store, REST/MCP update, and transclusion-unsync paths are
// now tested against their REAL code in:
// - collaboration/extensions/persistence.extension.html-embed.spec.ts
// - collaboration/collaboration.handler.html-embed.spec.ts
// - core/page/transclusion/spec/transclusion-unsync-html-embed.spec.ts
// - core/page/services/page-service-html-embed-identity.spec.ts (create/dup)
// - integrations/import/services/import-html-embed-identity.spec.ts (import)
// //
// The case below stays here because it asserts a REAL parse path // The case below asserts that the REAL parse path (htmlToJson, the markdown/html
// (htmlToJson, the markdown/html create format) feeding the REAL helpers — not a // form) produces an htmlEmbed node the master-toggle strip can detect & remove.
// re-implemented gate. describe('htmlEmbed via the markdown/html form (real parse + real strip helper)', () => {
describe('htmlEmbed smuggled via the markdown/html <!--html-embed--> form (real parse + real helpers)', () => { it('the parsed node is detected and stripped by the real helper', () => {
it('the parsed node is detected and stripped by the real helpers', () => { const source = '<script>track()</script>';
// The markdown/html create formats decode to the same htmlEmbed node, so the
// gate (run on the parsed JSON) covers them identically.
const source = '<script>steal()</script>';
const encoded = encodeHtmlEmbedSource(source); const encoded = encodeHtmlEmbedSource(source);
const html = `<div data-type="htmlEmbed" data-source="${encoded}"></div>`; const html = `<div data-type="htmlEmbed" data-source="${encoded}"></div>`;
const parsed = htmlToJson(html); const parsed = htmlToJson(html);
expect(hasHtmlEmbedNode(parsed)).toBe(true); expect(hasHtmlEmbedNode(parsed)).toBe(true);
// A non-admin role gates to strip via the real helpers.
expect(canAuthorHtmlEmbed('member')).toBe(false);
const stripped = stripHtmlEmbedNodes(parsed); const stripped = stripHtmlEmbedNodes(parsed);
expect(hasHtmlEmbedNode(stripped)).toBe(false); expect(hasHtmlEmbedNode(stripped)).toBe(false);
}); });

View File

@@ -5,12 +5,12 @@ export const HTML_EMBED_NODE_NAME = 'htmlEmbed';
/** /**
* Recursively remove every `htmlEmbed` node from a ProseMirror JSON document. * Recursively remove every `htmlEmbed` node from a ProseMirror JSON document.
* *
* SECURITY: `htmlEmbed` renders raw, unsanitized HTML/CSS/JS in the wiki origin * The `htmlEmbed` node renders inside a SANDBOXED iframe (no `allow-same-origin`)
* (stored-XSS by design, Variant C). Only workspace admins/owners are allowed to * on the client, so its content cannot touch the viewer's session/cookies/API —
* author it. This helper is the server-side enforcement primitive: every WRITE * it is NOT a stored-XSS surface. This helper is retained ONLY to honor the
* path that may persist content from a NON-admin caller must run the incoming * workspace master toggle (`settings.htmlEmbed`) on the anonymous public-share
* document through this function so a non-admin cannot smuggle the node in via * read path: an anonymous viewer cannot read the workspace toggle, so the server
* the collab socket, the REST/MCP/AI content-update path, paste, or import. * strips the block when the toggle is OFF before serving shared content.
* *
* Returns a NEW document; the input is not mutated. If the input is not a valid * Returns a NEW document; the input is not mutated. If the input is not a valid
* doc object it is returned unchanged (callers persist what they were given). * doc object it is returned unchanged (callers persist what they were given).
@@ -41,8 +41,8 @@ export function stripHtmlEmbedNodes<T = JSONContent>(pmJson: T): T {
/** /**
* Returns true if the document contains at least one `htmlEmbed` node anywhere * Returns true if the document contains at least one `htmlEmbed` node anywhere
* in its tree. Useful to decide whether a strip pass actually changed anything * in its tree. Useful to decide whether a strip pass on the share read path
* (e.g. for logging a rejected non-admin embed attempt). * actually changed anything.
*/ */
export function hasHtmlEmbedNode(pmJson: unknown): boolean { export function hasHtmlEmbedNode(pmJson: unknown): boolean {
if (!pmJson || typeof pmJson !== 'object') { if (!pmJson || typeof pmJson !== 'object') {
@@ -59,38 +59,9 @@ export function hasHtmlEmbedNode(pmJson: unknown): boolean {
} }
/** /**
* Map the workspace user role to whether it may author `htmlEmbed` nodes. * Read the workspace-level htmlEmbed master toggle from a workspace's settings
* Owners and admins are trusted; everyone else (member, and any unknown role) * jsonb. ABSENT/non-true => OFF (the default). Kept here so the share read path
* is not. Kept here so every write path shares one definition of "trusted". * resolves the toggle the same way it is persisted.
*/
export function canAuthorHtmlEmbed(role: string | null | undefined): boolean {
return role === 'owner' || role === 'admin';
}
/**
* Combined write-path gate for the htmlEmbed feature.
*
* htmlEmbed is allowed in a document only when the workspace feature toggle is
* ON and the authoring/saving user is a workspace admin/owner. OFF (default) =>
* stripped for EVERYONE, including admins (the feature is disabled).
*
* `featureEnabled` is read from the workspace settings for the relevant write
* (`workspace.settings?.htmlEmbed === true`). Every WRITE path that may persist
* htmlEmbed content must gate on this combined predicate, so that turning the
* toggle OFF strips existing embeds on the next save and prevents new ones from
* being persisted regardless of role.
*/
export function htmlEmbedAllowed(
featureEnabled: boolean,
role: string | null | undefined,
): boolean {
return featureEnabled === true && canAuthorHtmlEmbed(role);
}
/**
* Read the workspace-level htmlEmbed feature toggle from a workspace's settings
* jsonb. ABSENT/non-true => OFF (the default). Kept here so every server write
* path resolves the toggle the same way.
*/ */
export function isHtmlEmbedFeatureEnabled( export function isHtmlEmbedFeatureEnabled(
settings: unknown | null | undefined, settings: unknown | null | undefined,

View File

@@ -237,9 +237,6 @@ export class PageController {
user.id, user.id,
workspace.id, workspace.id,
createPageDto, createPageDto,
// Pass the caller's workspace role so create() can enforce the htmlEmbed
// admin gate (non-admins cannot author raw-JS embeds).
user.role,
provenance, provenance,
); );

View File

@@ -1,102 +0,0 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
hasHtmlEmbedNode,
htmlEmbedAllowed,
stripHtmlEmbedNodes,
} from '../../../common/helpers/prosemirror/html-embed.util';
// PageService.create() and duplicatePage() guards.
//
// page.service.ts cannot be unit-LOADED under the server's jest config (a
// transitive ESM dep, @sindresorhus/slugify, is not in transformIgnorePatterns),
// so we cover the two load-bearing properties at the strongest feasible layer:
//
// (1) BEHAVIOR — using the REAL html-embed helpers, replay the exact predicate
// each path applies: non-admin/unknown role -> strip, admin/owner -> keep.
//
// (2) IDENTITY — source-pin which role each path reads (create: the `callerRole`
// param threaded from the request; duplicate: `authUser.role`), so a
// refactor that drops the guard or reads the wrong role trips the test.
// This is what replaces the removed `applyAdminGate` stand-in for these
// two entrypoints.
const docWithEmbed = () => ({
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'body' }] },
{ type: 'htmlEmbed', attrs: { source: '<script>alert(1)</script>' } },
],
});
// The real predicate both paths apply (see SECURITY blocks in page.service.ts):
// toggle AND admin.
function applyGate(
json: any,
featureEnabled: boolean,
role: string | null | undefined,
) {
if (!htmlEmbedAllowed(featureEnabled, role) && hasHtmlEmbedNode(json)) {
return stripHtmlEmbedNodes(json);
}
return json;
}
describe('page create/duplicate gate decision (real helpers)', () => {
it('toggle ON + non-admin (member) strips', () => {
const result = applyGate(docWithEmbed(), true, 'member');
expect(hasHtmlEmbedNode(result)).toBe(false);
expect(result.content).toHaveLength(1);
expect(result.content[0].content[0].text).toBe('body');
});
it('toggle ON + unknown/empty role fails closed (strips)', () => {
for (const role of [null, undefined, 'viewer'] as const) {
expect(hasHtmlEmbedNode(applyGate(docWithEmbed(), true, role))).toBe(
false,
);
}
});
it('toggle ON + admin/owner keep', () => {
expect(hasHtmlEmbedNode(applyGate(docWithEmbed(), true, 'admin'))).toBe(
true,
);
expect(hasHtmlEmbedNode(applyGate(docWithEmbed(), true, 'owner'))).toBe(
true,
);
});
it('toggle OFF strips for everyone (admin/owner/member)', () => {
for (const role of ['admin', 'owner', 'member'] as const) {
expect(hasHtmlEmbedNode(applyGate(docWithEmbed(), false, role))).toBe(
false,
);
}
});
});
const SRC = readFileSync(join(__dirname, 'page.service.ts'), 'utf-8');
describe('page create/duplicate gate identity is pinned (source contract)', () => {
it('create() gates on toggle AND the caller role param before deriving content/ydoc', () => {
// create() receives the caller's workspace role as `callerRole` and gates on
// the combined toggle-AND-admin predicate; the embed must be stripped BEFORE
// insertPage.
expect(SRC).toMatch(
/!htmlEmbedAllowed\(\s*htmlEmbedEnabled\s*,\s*callerRole\s*\)\s*&&\s*hasHtmlEmbedNode\(\s*prosemirrorJson\s*\)/,
);
expect(SRC).toContain('prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson)');
});
it('duplicatePage() gates on toggle AND the duplicating user role (authUser.role)', () => {
expect(SRC).toMatch(
/!htmlEmbedAllowed\(\s*htmlEmbedEnabled\s*,\s*authUser\.role\s*\)\s*&&\s*hasHtmlEmbedNode\(\s*prosemirrorJson\s*\)/,
);
});
it('both paths resolve the toggle from the workspace settings', () => {
expect(SRC).toContain('isHtmlEmbedFeatureEnabled(');
expect(SRC).toContain('this.workspaceRepo.findById(');
});
});

View File

@@ -30,13 +30,6 @@ import {
isAttachmentNode, isAttachmentNode,
removeMarkTypeFromDoc, removeMarkTypeFromDoc,
} from '../../../common/helpers/prosemirror/utils'; } from '../../../common/helpers/prosemirror/utils';
import {
hasHtmlEmbedNode,
htmlEmbedAllowed,
isHtmlEmbedFeatureEnabled,
stripHtmlEmbedNodes,
} from '../../../common/helpers/prosemirror/html-embed.util';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { import {
htmlToJson, htmlToJson,
jsonToNode, jsonToNode,
@@ -81,7 +74,6 @@ export class PageService {
private collaborationGateway: CollaborationGateway, private collaborationGateway: CollaborationGateway,
private readonly watcherService: WatcherService, private readonly watcherService: WatcherService,
private readonly transclusionService: TransclusionService, private readonly transclusionService: TransclusionService,
private readonly workspaceRepo: WorkspaceRepo,
) {} ) {}
async findById( async findById(
@@ -101,10 +93,6 @@ export class PageService {
userId: string, userId: string,
workspaceId: string, workspaceId: string,
createPageDto: CreatePageDto, createPageDto: CreatePageDto,
// Workspace role of the caller. Used to enforce the htmlEmbed admin gate on
// the create write path (see below). Optional/typed loosely so unknown or
// missing roles fall through to the non-admin (strip) branch by default.
callerRole?: string | null,
// Optional agent-edit provenance (from the signed access claim). When the // Optional agent-edit provenance (from the signed access claim). When the
// actor is 'agent', stamp the page's source marker so a freshly created page // actor is 'agent', stamp the page's source marker so a freshly created page
// shows it was created by the AI agent (§14 N2) — create goes through REST, // shows it was created by the AI agent (§14 N2) — create goes through REST,
@@ -135,36 +123,11 @@ export class PageService {
let ydoc = undefined; let ydoc = undefined;
if (createPageDto?.content && createPageDto?.format) { if (createPageDto?.content && createPageDto?.format) {
let prosemirrorJson = await this.parseProsemirrorContent( const prosemirrorJson = await this.parseProsemirrorContent(
createPageDto.content, createPageDto.content,
createPageDto.format, createPageDto.format,
); );
// SECURITY (Variant C admin gate, plain page-create write path):
// create() builds content/textContent/ydoc directly and persists them via
// insertPage, bypassing the collab onStoreDocument strip. htmlEmbed renders
// raw, unsanitized JS in readers' browsers, so only workspace admins/owners
// may author it. The create controller requires only space Edit, so a
// regular member could otherwise POST a doc (json, or the markdown/html
// <!--html-embed:BASE64--> forms that parse to the same node) containing an
// htmlEmbed and store XSS for every reader. Strip every htmlEmbed node when
// the caller is not an admin, BEFORE deriving textContent/ydoc/insert.
// The gate is toggle-AND-admin: htmlEmbed survives only when the workspace
// feature toggle is ON and the caller is an admin/owner. OFF (default) =>
// stripped for everyone. Cheap settings read keyed to the workspace.
const htmlEmbedEnabled = isHtmlEmbedFeatureEnabled(
(await this.workspaceRepo.findById(workspaceId))?.settings,
);
if (
!htmlEmbedAllowed(htmlEmbedEnabled, callerRole) &&
hasHtmlEmbedNode(prosemirrorJson)
) {
this.logger.warn(
`Stripping htmlEmbed node(s) from page creation by user ${userId} (space ${createPageDto.spaceId})`,
);
prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson);
}
content = prosemirrorJson; content = prosemirrorJson;
textContent = jsonToText(prosemirrorJson); textContent = jsonToText(prosemirrorJson);
ydoc = createYdocFromJson(prosemirrorJson); ydoc = createYdocFromJson(prosemirrorJson);
@@ -627,12 +590,6 @@ export class PageService {
const attachmentMap = new Map<string, ICopyPageAttachment>(); const attachmentMap = new Map<string, ICopyPageAttachment>();
// Resolve the htmlEmbed toggle ONCE for the workspace; the per-page gate
// below is toggle-AND-admin (OFF default => stripped for everyone).
const htmlEmbedEnabled = isHtmlEmbedFeatureEnabled(
(await this.workspaceRepo.findById(rootPage.workspaceId))?.settings,
);
const insertablePages: InsertablePage[] = await Promise.all( const insertablePages: InsertablePage[] = await Promise.all(
pages.map(async (page) => { pages.map(async (page) => {
const pageContent = getProsemirrorContent(page.content); const pageContent = getProsemirrorContent(page.content);
@@ -744,25 +701,7 @@ export class PageService {
} }
}); });
let prosemirrorJson = prosemirrorDoc.toJSON(); const prosemirrorJson = prosemirrorDoc.toJSON();
// SECURITY (Variant C admin gate, duplication write path):
// Duplication builds the ydoc directly and bypasses the collab
// onStoreDocument strip. htmlEmbed renders raw, unsanitized JS in
// readers' browsers, so only workspace admins/owners may author it. A
// non-admin with space Edit could otherwise duplicate an admin page
// that contains an embed into a new page authored by them. Strip every
// htmlEmbed node from each duplicated page when the duplicating user is
// not an admin, BEFORE computing textContent/ydoc/insert.
if (
!htmlEmbedAllowed(htmlEmbedEnabled, authUser.role) &&
hasHtmlEmbedNode(prosemirrorJson)
) {
this.logger.warn(
`Stripping htmlEmbed node(s) from page duplication by user ${authUser.id} (source page ${page.id})`,
);
prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson);
}
// Add "Copy of " prefix to the root page title only for duplicates in same space // Add "Copy of " prefix to the root page title only for duplicates in same space
let title = page.title; let title = page.title;

View File

@@ -68,7 +68,6 @@ describe('TransclusionService — template access core (real filter)', () => {
{} as any, // attachmentRepo {} as any, // attachmentRepo
{} as any, // storageService {} as any, // storageService
{} as any, // pageAccessService {} as any, // pageAccessService
{} as any, // workspaceRepo
); );
return { service, db, pageRepo, spaceMemberRepo, pagePermissionRepo }; return { service, db, pageRepo, spaceMemberRepo, pagePermissionRepo };
@@ -222,7 +221,6 @@ describe('TransclusionService.filterViewerAccessiblePageIds — AND ordering (co
{} as any, // attachmentRepo {} as any, // attachmentRepo
{} as any, // storageService {} as any, // storageService
{} as any, // pageAccessService {} as any, // pageAccessService
{} as any, // workspaceRepo
); );
return { service, filterAccessiblePageIds }; return { service, filterAccessiblePageIds };
@@ -319,7 +317,6 @@ describe('TransclusionService.syncPageTemplateReferences — workspace scoping',
{} as any, // attachmentRepo {} as any, // attachmentRepo
{} as any, // storageService {} as any, // storageService
{} as any, // pageAccessService {} as any, // pageAccessService
{} as any, // workspaceRepo
); );
return { return {
@@ -464,7 +461,6 @@ describe('TransclusionService.insertTemplateReferencesForPages — per-workspace
{} as any, // attachmentRepo {} as any, // attachmentRepo
{} as any, // storageService {} as any, // storageService
{} as any, // pageAccessService {} as any, // pageAccessService
{} as any, // workspaceRepo
); );
return { service, insertMany }; return { service, insertMany };
} }

View File

@@ -35,7 +35,6 @@ describe('TransclusionService.lookupTemplate (access mapping)', () => {
{} as any, // attachmentRepo {} as any, // attachmentRepo
{} as any, // storageService {} as any, // storageService
{} as any, // pageAccessService {} as any, // pageAccessService
{} as any, // workspaceRepo
); );
jest jest

View File

@@ -1,145 +0,0 @@
import { TransclusionService } from '../transclusion.service';
import { hasHtmlEmbedNode } from '../../../../common/helpers/prosemirror/html-embed.util';
// Exercises the REAL TransclusionService.unsyncReference htmlEmbed admin gate.
// unsync returns a source snapshot the client materializes into the reference
// page; a non-admin must never receive an embed payload to re-persist. The gate
// reads `user.role` and strips before returning. All repos / access checks are
// mocked so the REAL gate logic runs end-to-end. Complements the existing
// transclusion specs (rewriteAttachmentsForUnsync, controller).
const WS = 'ws-1';
const REF_PAGE = 'ref-1';
const SRC_PAGE = 'src-1';
const TX_ID = 'tx-1';
const sourceContentWithEmbed = () => ({
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'snapshot body' }] },
{ type: 'htmlEmbed', attrs: { source: '<script>steal()</script>' } },
],
});
function buildService(featureEnabled = true) {
const pageRepo = {
findById: jest.fn(async (id: string) => ({
id,
workspaceId: WS,
spaceId: 'space-1',
deletedAt: null,
})),
};
const pageTransclusionsRepo = {
findByPageAndTransclusion: jest.fn(async () => ({
content: sourceContentWithEmbed(),
})),
};
const pageTransclusionReferencesRepo = {
deleteOne: jest.fn(async () => undefined),
};
const attachmentRepo = { findByIds: jest.fn(async () => []) };
const storageService = { copy: jest.fn(async () => undefined) };
const pageAccessService = {
validateCanEdit: jest.fn(async () => undefined),
validateCanView: jest.fn(async () => undefined),
};
// Workspace settings read used by the toggle-AND-admin gate.
const workspaceRepo = {
findById: jest.fn(async () => ({
id: WS,
settings: { htmlEmbed: featureEnabled },
})),
};
const service = new TransclusionService(
{} as any, // db (unused on this path)
pageTransclusionsRepo as any,
pageTransclusionReferencesRepo as any,
{} as any, // pageTemplateReferencesRepo (unused on this path)
pageRepo as any,
{} as any, // pagePermissionRepo (unused)
{} as any, // spaceMemberRepo (unused)
attachmentRepo as any,
storageService as any,
pageAccessService as any,
workspaceRepo as any,
);
return service;
}
function userWithRole(role: string | null | undefined) {
return { id: 'u1', workspaceId: WS, role } as any;
}
describe('TransclusionService.unsyncReference htmlEmbed admin gate (real code)', () => {
it('non-admin (member): returned content has htmlEmbed stripped', async () => {
const service = buildService();
const { content } = await service.unsyncReference(
REF_PAGE,
SRC_PAGE,
TX_ID,
userWithRole('member'),
);
expect(hasHtmlEmbedNode(content)).toBe(false);
// Non-embed content is preserved.
expect(JSON.stringify(content)).toContain('snapshot body');
});
it('unknown/empty role: fails closed (stripped)', async () => {
for (const role of [undefined, null, 'viewer'] as const) {
const service = buildService();
const { content } = await service.unsyncReference(
REF_PAGE,
SRC_PAGE,
TX_ID,
userWithRole(role),
);
expect(hasHtmlEmbedNode(content)).toBe(false);
}
});
it('toggle ON + admin: returned content keeps the htmlEmbed', async () => {
const service = buildService(true);
const { content } = await service.unsyncReference(
REF_PAGE,
SRC_PAGE,
TX_ID,
userWithRole('admin'),
);
expect(hasHtmlEmbedNode(content)).toBe(true);
});
it('toggle ON + owner: returned content keeps the htmlEmbed', async () => {
const service = buildService(true);
const { content } = await service.unsyncReference(
REF_PAGE,
SRC_PAGE,
TX_ID,
userWithRole('owner'),
);
expect(hasHtmlEmbedNode(content)).toBe(true);
});
it('toggle OFF + admin: stripped (feature disabled for everyone)', async () => {
const service = buildService(false);
const { content } = await service.unsyncReference(
REF_PAGE,
SRC_PAGE,
TX_ID,
userWithRole('admin'),
);
expect(hasHtmlEmbedNode(content)).toBe(false);
});
it('toggle OFF + member: stripped', async () => {
const service = buildService(false);
const { content } = await service.unsyncReference(
REF_PAGE,
SRC_PAGE,
TX_ID,
userWithRole('member'),
);
expect(hasHtmlEmbedNode(content)).toBe(false);
});
});

View File

@@ -33,13 +33,6 @@ import {
import { jsonToNode } from '../../../collaboration/collaboration.util'; import { jsonToNode } from '../../../collaboration/collaboration.util';
import { Page, User } from '@docmost/db/types/entity.types'; import { Page, User } from '@docmost/db/types/entity.types';
import { PageAccessService } from '../page-access/page-access.service'; import { PageAccessService } from '../page-access/page-access.service';
import {
hasHtmlEmbedNode,
htmlEmbedAllowed,
isHtmlEmbedFeatureEnabled,
stripHtmlEmbedNodes,
} from '../../../common/helpers/prosemirror/html-embed.util';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
type ReferencingPageInfo = { type ReferencingPageInfo = {
id: string; id: string;
@@ -65,7 +58,6 @@ export class TransclusionService {
private readonly attachmentRepo: AttachmentRepo, private readonly attachmentRepo: AttachmentRepo,
private readonly storageService: StorageService, private readonly storageService: StorageService,
private readonly pageAccessService: PageAccessService, private readonly pageAccessService: PageAccessService,
private readonly workspaceRepo: WorkspaceRepo,
) {} ) {}
async syncPageTransclusions( async syncPageTransclusions(
@@ -753,24 +745,6 @@ export class TransclusionService {
transclusionId, transclusionId,
); );
// SECURITY (Variant C admin gate, transclusion unsync write path):
// The returned content is a source snapshot that the client materializes
// into the reference page via insertContentAt. The snapshot keeps any
// htmlEmbed verbatim, and unsync requires only space Edit/View. If the
// requesting user is not a workspace admin/owner, strip htmlEmbed nodes so a
// non-admin can never receive an embed payload to re-persist (the collab
// strip on the subsequent save is debounced/race-prone and must not be the
// only guard). Admin behavior is unchanged.
const htmlEmbedEnabled = isHtmlEmbedFeatureEnabled(
(await this.workspaceRepo.findById(user.workspaceId))?.settings,
);
if (!htmlEmbedAllowed(htmlEmbedEnabled, user.role) && hasHtmlEmbedNode(content)) {
this.logger.warn(
`Stripping htmlEmbed node(s) from transclusion unsync by user ${user.id} (reference page ${referencePageId}, source page ${sourcePageId})`,
);
content = stripHtmlEmbedNodes(content);
}
return { content }; return { content };
} }
} }

View File

@@ -1,12 +1,14 @@
import { ShareService } from './share.service'; import { ShareService } from './share.service';
import { hasHtmlEmbedNode } from '../../common/helpers/prosemirror/html-embed.util'; import { hasHtmlEmbedNode } from '../../common/helpers/prosemirror/html-embed.util';
// Exercises the REAL ShareService server-authoritative htmlEmbed kill-switch for // Exercises the REAL ShareService server-authoritative htmlEmbed master toggle
// shared content. An anonymous public-share viewer cannot read the per-workspace // for shared content. The block renders inside a sandboxed iframe (harmless), so
// htmlEmbed toggle, so the SERVER must decide what to serve: when the toggle is // this is NOT an XSS guard — it is the master-toggle enforcement for anonymous
// OFF, htmlEmbed nodes are stripped from the shared doc; when ON they are kept so // shares: an anonymous public-share viewer cannot read the per-workspace
// the read-only client executes them. All repos / token service are mocked so the // htmlEmbed toggle, so the SERVER must decide what to serve. When the toggle is
// real prepareContentForShare logic runs end-to-end via getSharedPage. // OFF, htmlEmbed nodes are stripped from the shared doc; when ON they are served
// and rendered in their sandboxed frame. All repos / token service are mocked so
// the real prepareContentForShare logic runs end-to-end via getSharedPage.
const WS = 'ws-1'; const WS = 'ws-1';
const PAGE = 'page-1'; const PAGE = 'page-1';

View File

@@ -84,10 +84,24 @@ export class ShareSeoController {
.join('\n '); .join('\n ');
const html = fs.readFileSync(indexFilePath, 'utf8'); const html = fs.readFileSync(indexFilePath, 'utf8');
const transformedHtml = html let transformedHtml = html
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${metaTitle}</title>`) .replace(/<title>[\s\S]*?<\/title>/i, `<title>${metaTitle}</title>`)
.replace(metaTagVar, metaTags); .replace(metaTagVar, metaTags);
// Deliberate same-origin tracker surface: this is the ONE place where an
// admin-authored analytics/tracker snippet (settings.trackerHead) is
// injected verbatim into the page origin. It is admin-only (writable only
// via the admin-gated workspace settings) and applies to PUBLIC SHARE
// pages only. It is trusted content, so it is NOT escaped. The htmlEmbed
// block itself is sandboxed and is the safe surface for everyone else.
const trackerHead = (workspace?.settings as any)?.trackerHead;
if (typeof trackerHead === 'string' && trackerHead.trim().length > 0) {
transformedHtml = transformedHtml.replace(
'</head>',
`${trackerHead}\n</head>`,
);
}
res.type('text/html').send(transformedHtml); res.type('text/html').send(transformedHtml);
} }
} }

View File

@@ -470,12 +470,14 @@ export class ShareService {
* not leak structure (existence, location, count, resolved state, or * not leak structure (existence, location, count, resolved state, or
* comment ids) to public viewers. * comment ids) to public viewers.
* *
* 3. Strip `htmlEmbed` nodes when the workspace feature toggle is OFF. This * 3. Strip `htmlEmbed` nodes when the workspace master toggle is OFF. The
* makes the toggle a SERVER-AUTHORITATIVE kill-switch for shared content: * block renders inside a sandboxed iframe on the client (harmless, no
* when OFF the embed is never served to the anonymous viewer (who can't * same-origin access), so this is NOT an XSS guard — it is the
* read the per-workspace toggle), when ON the embed is served so the * SERVER-AUTHORITATIVE enforcement of the workspace master toggle for
* read-only client executes it. `htmlEmbedEnabled` is resolved fail-closed * anonymous shares: an anonymous viewer cannot read the per-workspace
* by the callers (missing workspace => OFF => strip). * toggle, so when OFF the block is never served, and when ON it is served
* and rendered in its sandboxed frame. `htmlEmbedEnabled` is resolved
* fail-closed by the callers (missing workspace => OFF => strip).
* *
* Both share-content paths — the host page (`updatePublicAttachments`) and * Both share-content paths — the host page (`updatePublicAttachments`) and
* the share-scoped transclusion lookup (`lookupTransclusionForShare`) — * the share-scoped transclusion lookup (`lookupTransclusionForShare`) —
@@ -490,8 +492,9 @@ export class ShareService {
): Promise<Node | null> { ): Promise<Node | null> {
let pmJson = getProsemirrorContent(content); let pmJson = getProsemirrorContent(content);
// Kill-switch: when the workspace toggle is OFF, never serve htmlEmbed // Master-toggle enforcement: when the workspace toggle is OFF, never serve
// nodes to public viewers. Strip before tokenizing/serializing. // htmlEmbed nodes to anonymous public viewers (who cannot read the toggle).
// Strip before tokenizing/serializing.
if (!htmlEmbedEnabled) { if (!htmlEmbedEnabled) {
pmJson = stripHtmlEmbedNodes(pmJson); pmJson = stripHtmlEmbedNodes(pmJson);
} }

View File

@@ -5,6 +5,8 @@ import {
IsBoolean, IsBoolean,
IsInt, IsInt,
IsOptional, IsOptional,
IsString,
MaxLength,
Min, Min,
} from 'class-validator'; } from 'class-validator';
@@ -53,12 +55,22 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsBoolean() @IsBoolean()
aiDictation: boolean; aiDictation: boolean;
// Workspace feature toggle for the admin-only HTML embed feature. Persisted at // Workspace master toggle that enables/disables the HTML embed block type.
// settings.htmlEmbed. ABSENT/false => OFF (default). // Persisted at settings.htmlEmbed. ABSENT/false => OFF (default). The block
// itself renders in a sandboxed iframe, so this is a feature switch, not a
// security gate.
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
htmlEmbed: boolean; htmlEmbed: boolean;
// Admin-only analytics/tracker snippet (raw HTML/JS) injected verbatim into
// the <head> of PUBLIC SHARE pages only (same-origin). Persisted at
// settings.trackerHead. Admin-authored trusted content.
@IsOptional()
@IsString()
@MaxLength(20000)
trackerHead?: string;
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
aiPublicShareAssistant: boolean; aiPublicShareAssistant: boolean;

View File

@@ -108,4 +108,38 @@ describe('WorkspaceService.update — htmlEmbed toggle persistence (real code)',
expect(logged.changes.before.htmlEmbed).toBe(false); expect(logged.changes.before.htmlEmbed).toBe(false);
expect(logged.changes.after.htmlEmbed).toBe(true); expect(logged.changes.after.htmlEmbed).toBe(true);
}); });
it('persists trackerHead via updateSetting with the trackerHead key', async () => {
const { service, updateSetting } = buildService({});
await service.update('w1', { trackerHead: '<script>ga()</script>' } as any);
expect(updateSetting).toHaveBeenCalledWith(
'w1',
'trackerHead',
'<script>ga()</script>',
expect.anything(),
);
});
it('does NOT call updateSetting when trackerHead is undefined in the dto', async () => {
const { service, updateSetting } = buildService({});
await service.update('w1', { name: 'New name' } as any);
expect(updateSetting).not.toHaveBeenCalled();
});
it('audits the trackerHead change (before/after) when the value changes', async () => {
const { service, auditService } = buildService({
settingsBefore: { trackerHead: '' },
});
await service.update('w1', { trackerHead: '<script>m()</script>' } as any);
expect(auditService.log).toHaveBeenCalledTimes(1);
const logged = auditService.log.mock.calls[0][0];
expect(logged.changes.before.trackerHead).toBe('');
expect(logged.changes.after.trackerHead).toBe('<script>m()</script>');
});
}); });

View File

@@ -525,6 +525,22 @@ export class WorkspaceService {
); );
} }
if (typeof updateWorkspaceDto.trackerHead !== 'undefined') {
// Admin-only analytics/tracker snippet injected into the <head> of
// public share pages (same-origin). Persisted at settings.trackerHead.
const prev = (settingsBefore as any)?.trackerHead ?? '';
if (prev !== updateWorkspaceDto.trackerHead) {
before.trackerHead = prev;
after.trackerHead = updateWorkspaceDto.trackerHead;
}
await this.workspaceRepo.updateSetting(
workspaceId,
'trackerHead',
updateWorkspaceDto.trackerHead,
trx,
);
}
if (typeof updateWorkspaceDto.aiPublicShareAssistant !== 'undefined') { if (typeof updateWorkspaceDto.aiPublicShareAssistant !== 'undefined') {
const prev = settingsBefore?.ai?.publicShareAssistant ?? false; const prev = settingsBefore?.ai?.publicShareAssistant ?? false;
if (prev !== updateWorkspaceDto.aiPublicShareAssistant) { if (prev !== updateWorkspaceDto.aiPublicShareAssistant) {
@@ -549,6 +565,7 @@ export class WorkspaceService {
delete updateWorkspaceDto.aiChat; delete updateWorkspaceDto.aiChat;
delete updateWorkspaceDto.aiDictation; delete updateWorkspaceDto.aiDictation;
delete updateWorkspaceDto.htmlEmbed; delete updateWorkspaceDto.htmlEmbed;
delete updateWorkspaceDto.trackerHead;
delete updateWorkspaceDto.aiPublicShareAssistant; delete updateWorkspaceDto.aiPublicShareAssistant;
await this.workspaceRepo.updateWorkspace( await this.workspaceRepo.updateWorkspace(

View File

@@ -20,14 +20,6 @@ import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import { FileTask, InsertablePage } from '@docmost/db/types/entity.types'; import { FileTask, InsertablePage } from '@docmost/db/types/entity.types';
import { markdownToHtml } from '@docmost/editor-ext'; import { markdownToHtml } from '@docmost/editor-ext';
import { getProsemirrorContent } from '../../../common/helpers/prosemirror/utils'; import { getProsemirrorContent } from '../../../common/helpers/prosemirror/utils';
import {
hasHtmlEmbedNode,
htmlEmbedAllowed,
isHtmlEmbedFeatureEnabled,
stripHtmlEmbedNodes,
} from '../../../common/helpers/prosemirror/html-embed.util';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { formatImportHtml } from '../utils/import-formatter'; import { formatImportHtml } from '../utils/import-formatter';
import { import {
buildAttachmentCandidates, buildAttachmentCandidates,
@@ -61,8 +53,6 @@ export class FileImportTaskService {
private readonly backlinkRepo: BacklinkRepo, private readonly backlinkRepo: BacklinkRepo,
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
private readonly importAttachmentService: ImportAttachmentService, private readonly importAttachmentService: ImportAttachmentService,
private readonly userRepo: UserRepo,
private readonly workspaceRepo: WorkspaceRepo,
private eventEmitter: EventEmitter2, private eventEmitter: EventEmitter2,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {} ) {}
@@ -159,29 +149,6 @@ export class FileImportTaskService {
.where('id', '=', fileTask.spaceId) .where('id', '=', fileTask.spaceId)
.executeTakeFirst(); .executeTakeFirst();
// SECURITY (Variant C admin gate, zip/multi-file import write path):
// An imported .html/.md file can carry an htmlEmbed marker (the node's
// serialized form), which would execute raw, unsanitized JS in readers'
// browsers. Only workspace admins/owners may author it. Resolve the
// importer's role ONCE here; each page's prosemirror JSON is run through the
// strip below before textContent/ydoc/insert when the importer is not an
// admin, so a non-admin cannot smuggle the node in via a zip import (which
// requires only space Edit).
const importingUser = await this.userRepo.findById(
fileTask.creatorId,
fileTask.workspaceId,
);
// Toggle-AND-admin gate, resolved ONCE for the whole import: htmlEmbed
// survives only when the workspace feature toggle is ON and the importer is
// an admin/owner. OFF (default) => stripped for everyone.
const htmlEmbedEnabled = isHtmlEmbedFeatureEnabled(
(await this.workspaceRepo.findById(fileTask.workspaceId))?.settings,
);
const importerCanAuthorHtmlEmbed = htmlEmbedAllowed(
htmlEmbedEnabled,
importingUser?.role,
);
const pagesMap = new Map<string, ImportPageNode>(); const pagesMap = new Map<string, ImportPageNode>();
for (const absPath of allFiles) { for (const absPath of allFiles) {
@@ -529,21 +496,9 @@ export class FileImportTaskService {
await this.importService.processHTML(html), await this.importService.processHTML(html),
); );
let { title, prosemirrorJson } = const { title, prosemirrorJson } =
this.importService.extractTitleAndRemoveHeading(pmState); this.importService.extractTitleAndRemoveHeading(pmState);
// SECURITY (Variant C admin gate): strip htmlEmbed nodes from pages
// imported by a non-admin BEFORE computing textContent/ydoc/insert.
if (
!importerCanAuthorHtmlEmbed &&
hasHtmlEmbedNode(prosemirrorJson)
) {
this.logger.warn(
`Stripping htmlEmbed node(s) from non-admin import by user ${fileTask.creatorId} (page ${page.id}, file ${filePath})`,
);
prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson);
}
const insertablePage: InsertablePage = { const insertablePage: InsertablePage = {
id: page.id, id: page.id,
slugId: page.slugId, slugId: page.slugId,

View File

@@ -1,123 +0,0 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
hasHtmlEmbedNode,
htmlEmbedAllowed,
stripHtmlEmbedNodes,
} from '../../../common/helpers/prosemirror/html-embed.util';
// FAIL-CLOSED IDENTITY for the import write paths.
//
// import.service / file-import-task.service cannot be unit-LOADED under the
// server's jest config (a transitive ESM dep, @sindresorhus/slugify, is not in
// transformIgnorePatterns). So we cover the two load-bearing properties at the
// strongest feasible layer:
//
// (1) BEHAVIOR — using the REAL html-embed helpers, replay the exact gate
// predicate each entrypoint runs against the role resolved from
// userRepo.findById(...): a MISSING user (findById -> undefined) must fail
// closed (strip), and only 'admin'/'owner' keep the embed.
//
// (2) IDENTITY — source-pin which identity governs the gate so a refactor that
// swaps the lookup to the wrong user (e.g. the queue worker's caller) is
// caught: zip import resolves the role from `fileTask.creatorId`; single
// import from the request `userId`. NOT some ambient caller.
//
// If a guard is deleted/misplaced or the identity field changes, these break.
const docWithEmbed = () => ({
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'imported body' }] },
{ type: 'htmlEmbed', attrs: { source: '<script>x</script>' } },
],
});
// The real predicate both import entrypoints apply (see the SECURITY blocks in
// import.service.ts and file-import-task.service.ts): resolve the importer via
// userRepo.findById, then `!canAuthorHtmlEmbed(role) && hasHtmlEmbedNode(json)`.
function applyImportGate(
json: any,
featureEnabled: boolean,
importingUser: { role?: string } | undefined,
) {
if (
!htmlEmbedAllowed(featureEnabled, importingUser?.role) &&
hasHtmlEmbedNode(json)
) {
return stripHtmlEmbedNodes(json);
}
return json;
}
describe('import gate fail-closed by toggle AND resolved-user role (real helpers)', () => {
it('toggle ON + missing user (userRepo.findById -> undefined) strips the embed', () => {
// findById returns undefined when the user/workspace pair does not resolve;
// undefined?.role is undefined -> htmlEmbedAllowed(true, undefined) === false.
const result = applyImportGate(docWithEmbed(), true, undefined);
expect(hasHtmlEmbedNode(result)).toBe(false);
});
it("toggle ON + resolved role 'member' strips", () => {
expect(
hasHtmlEmbedNode(applyImportGate(docWithEmbed(), true, { role: 'member' })),
).toBe(false);
});
it("toggle ON + resolved role 'admin' keeps the embed", () => {
expect(
hasHtmlEmbedNode(applyImportGate(docWithEmbed(), true, { role: 'admin' })),
).toBe(true);
});
it("toggle ON + resolved role 'owner' keeps the embed", () => {
expect(
hasHtmlEmbedNode(applyImportGate(docWithEmbed(), true, { role: 'owner' })),
).toBe(true);
});
it('toggle OFF strips for every role (admin/owner/member)', () => {
for (const role of ['admin', 'owner', 'member'] as const) {
expect(
hasHtmlEmbedNode(applyImportGate(docWithEmbed(), false, { role })),
).toBe(false);
}
});
});
// Source-pin the identity each entrypoint feeds to userRepo.findById. These are
// the lines that decide WHOSE role governs the gate; pinning them means a
// refactor that points the lookup at the wrong user trips the test.
const SRC_DIR = join(__dirname);
describe('import gate identity is pinned to the importer (source contract)', () => {
it('single import resolves the role from the request userId', () => {
const src = readFileSync(join(SRC_DIR, 'import.service.ts'), 'utf-8');
// The role lookup must key on the request `userId`, then gate on the role.
expect(src).toMatch(
/this\.userRepo\.findById\(\s*userId\s*,\s*workspaceId\s*\)/,
);
expect(src).toMatch(
/htmlEmbedAllowed\(\s*htmlEmbedEnabled\s*,\s*importingUser\?\.role\s*\)/,
);
// And the toggle is resolved from the workspace settings.
expect(src).toContain('isHtmlEmbedFeatureEnabled(');
// And the gate uses the real strip helper.
expect(src).toContain('stripHtmlEmbedNodes(prosemirrorJson)');
});
it('zip import resolves the role from fileTask.creatorId (NOT the queue caller)', () => {
const src = readFileSync(
join(SRC_DIR, 'file-import-task.service.ts'),
'utf-8',
);
expect(src).toMatch(
/this\.userRepo\.findById\(\s*fileTask\.creatorId\s*,\s*fileTask\.workspaceId\s*,?\s*\)/,
);
expect(src).toMatch(
/importerCanAuthorHtmlEmbed\s*=\s*htmlEmbedAllowed\(\s*htmlEmbedEnabled\s*,\s*importingUser\?\.role\s*,?\s*\)/,
);
expect(src).toContain('isHtmlEmbedFeatureEnabled(');
expect(src).toContain('stripHtmlEmbedNodes(prosemirrorJson)');
});
});

View File

@@ -1,13 +1,5 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import {
hasHtmlEmbedNode,
htmlEmbedAllowed,
isHtmlEmbedFeatureEnabled,
stripHtmlEmbedNodes,
} from '../../../common/helpers/prosemirror/html-embed.util';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { MultipartFile } from '@fastify/multipart'; import { MultipartFile } from '@fastify/multipart';
import * as path from 'path'; import * as path from 'path';
import { import {
@@ -45,12 +37,10 @@ export class ImportService {
constructor( constructor(
private readonly pageRepo: PageRepo, private readonly pageRepo: PageRepo,
private readonly userRepo: UserRepo,
private readonly storageService: StorageService, private readonly storageService: StorageService,
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.FILE_TASK_QUEUE) @InjectQueue(QueueName.FILE_TASK_QUEUE)
private readonly fileTaskQueue: Queue, private readonly fileTaskQueue: Queue,
private readonly workspaceRepo: WorkspaceRepo,
) {} ) {}
async importPage( async importPage(
@@ -95,28 +85,7 @@ export class ImportService {
const extracted = this.extractTitleAndRemoveHeading(prosemirrorState); const extracted = this.extractTitleAndRemoveHeading(prosemirrorState);
const title = extracted.title; const title = extracted.title;
let prosemirrorJson = extracted.prosemirrorJson; const prosemirrorJson = extracted.prosemirrorJson;
// SECURITY (Variant C admin gate, import write path):
// An imported .html/.md file can carry an htmlEmbed marker (the node's
// serialized form), which would execute raw JS in readers' browsers. Only
// workspace admins/owners may author it, so strip htmlEmbed nodes from
// imports performed by a non-admin user.
if (prosemirrorJson && hasHtmlEmbedNode(prosemirrorJson)) {
const importingUser = await this.userRepo.findById(userId, workspaceId);
// Toggle-AND-admin gate: htmlEmbed survives only when the workspace
// feature toggle is ON and the importer is an admin/owner. OFF (default)
// => stripped for everyone.
const htmlEmbedEnabled = isHtmlEmbedFeatureEnabled(
(await this.workspaceRepo.findById(workspaceId))?.settings,
);
if (!htmlEmbedAllowed(htmlEmbedEnabled, importingUser?.role)) {
this.logger.warn(
`Stripping htmlEmbed node(s) from import by user ${userId}`,
);
prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson);
}
}
const pageTitle = title || fileName; const pageTitle = title || fileName;

View File

@@ -7,8 +7,10 @@ export interface HtmlEmbedOptions {
} }
export interface HtmlEmbedAttributes { export interface HtmlEmbedAttributes {
// Raw HTML/CSS/JS string that is injected verbatim into the wiki origin. // Raw HTML/CSS/JS string rendered inside a sandboxed iframe by the NodeView.
source?: string; source?: string;
// Fixed iframe height in pixels. null/absent => auto-resize via postMessage.
height?: number | null;
} }
declare module "@tiptap/core" { declare module "@tiptap/core" {
@@ -90,6 +92,16 @@ export const HtmlEmbed = Node.create<HtmlEmbedOptions>({
"data-source": encodeHtmlEmbedSource(attributes.source || ""), "data-source": encodeHtmlEmbedSource(attributes.source || ""),
}), }),
}, },
// Fixed iframe height in px. null/absent => auto-resize on the client.
height: {
default: null,
parseHTML: (el) => {
const v = el.getAttribute("data-height");
return v ? parseInt(v, 10) : null;
},
renderHTML: (attrs: HtmlEmbedAttributes) =>
attrs.height ? { "data-height": String(attrs.height) } : {},
},
}; };
}, },