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:
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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(');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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) } : {},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user