diff --git a/apps/client/src/features/editor/components/html-embed/html-embed-view.tsx b/apps/client/src/features/editor/components/html-embed/html-embed-view.tsx index 4d9a1bb5..7128497b 100644 --- a/apps/client/src/features/editor/components/html-embed/html-embed-view.tsx +++ b/apps/client/src/features/editor/components/html-embed/html-embed-view.tsx @@ -11,7 +11,9 @@ import { } from "@mantine/core"; import { IconCode, IconEdit } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; +import { useAtomValue } from "jotai"; import useUserRole from "@/hooks/use-user-role.tsx"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import classes from "./html-embed-view.module.css"; /** @@ -53,18 +55,29 @@ export default function HtmlEmbedView(props: NodeViewProps) { const { source } = node.attrs as { source: string }; const { isAdmin } = useUserRole(); + // Defense in depth: only execute the raw HTML/JS when the workspace HTML embed + // feature toggle is ON. When OFF (the default), we render a neutral disabled + // placeholder and inject nothing — so turning the feature off neutralizes + // existing embeds at render time as well as on the next server-side save. + const workspace = useAtomValue(workspaceAtom); + const htmlEmbedEnabled = workspace?.settings?.htmlEmbed === true; + const contentRef = useRef(null); const [modalOpen, setModalOpen] = useState(false); const [draft, setDraft] = useState(source || ""); // (Re)render the raw source whenever it changes. This runs in BOTH the // editable editor and the read-only / public-share editor (same NodeView), - // so trackers fire for readers too — that is the intended behaviour. + // so trackers fire for readers too — that is the intended behaviour. When the + // feature toggle is OFF we clear the container and inject/execute nothing. useEffect(() => { - if (contentRef.current) { + if (!contentRef.current) return; + if (htmlEmbedEnabled) { renderRawHtml(contentRef.current, source || ""); + } else { + contentRef.current.innerHTML = ""; } - }, [source]); + }, [source, htmlEmbedEnabled]); const openEditor = useCallback(() => { setDraft(source || ""); @@ -78,9 +91,10 @@ export default function HtmlEmbedView(props: NodeViewProps) { setModalOpen(false); }, [draft, editor.isEditable, updateAttributes]); - // The edit affordance is only meaningful in edit mode, and authoring is - // restricted to admins (the server strips the node for non-admins anyway). - const canEdit = editor.isEditable && isAdmin; + // The edit affordance is only meaningful in edit mode, is restricted to admins + // (the server strips the node for non-admins anyway), and is offered only when + // the workspace feature toggle is ON. + const canEdit = editor.isEditable && isAdmin && htmlEmbedEnabled; return ( )} - {source ? ( + {!htmlEmbedEnabled ? ( + // Feature disabled for this workspace: never inject/execute the source. + // Show a neutral placeholder so an existing embed is visibly inert. +
+ + + {t("HTML embed is disabled in this workspace")} + +
+ ) : source ? ( // Raw HTML/CSS/JS rendered into the wiki origin. Scripts are re-created // in renderRawHtml so they execute.
diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index b2c5c33f..bb52fbca 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -593,6 +593,7 @@ const CommandGroups: SlashMenuGroupedItemsType = { searchTerms: ["html", "css", "js", "javascript", "script", "tracker", "analytics", "raw", "embed"], icon: IconCode, adminOnly: true, + requiresHtmlEmbedFeature: true, command: ({ editor, range }: CommandProps) => { editor .chain() @@ -777,6 +778,25 @@ function isCurrentUserAdmin(): boolean { } } +/** + * Read the workspace-level HTML embed feature toggle from the persisted + * `currentUser` payload (the same localStorage entry `currentUserAtom` writes, + * carrying `workspace.settings`). ABSENT/false => OFF (the default). The slash + * `getSuggestionItems` is a plain function (no React/atom context), so we read + * the persisted state the same way `isCurrentUserAdmin()` does. UI gate only; + * the server independently strips htmlEmbed from every non-allowed write. + */ +function isHtmlEmbedFeatureEnabled(): boolean { + try { + const raw = localStorage.getItem("currentUser"); + if (!raw) return false; + const parsed = JSON.parse(raw); + return parsed?.workspace?.settings?.htmlEmbed === true; + } catch { + return false; + } +} + export const getSuggestionItems = ({ query, excludeItems, @@ -787,6 +807,7 @@ export const getSuggestionItems = ({ const search = query.toLowerCase(); const filteredGroups: SlashMenuGroupedItemsType = {}; const isAdmin = isCurrentUserAdmin(); + const htmlEmbedFeatureEnabled = isHtmlEmbedFeatureEnabled(); const fuzzyMatch = (query: string, target: string) => { let queryIndex = 0; @@ -803,6 +824,9 @@ export const getSuggestionItems = ({ if (excludeItems?.has(item.title)) return false; // Hide admin-only items (raw HTML embed) from non-admins. if (item.adminOnly && !isAdmin) return false; + // Hide HTML-embed-gated items unless the workspace feature toggle is ON. + if (item.requiresHtmlEmbedFeature && !htmlEmbedFeatureEnabled) + return false; return ( fuzzyMatch(search, item.title) || item.description.toLowerCase().includes(search) || diff --git a/apps/client/src/features/editor/components/slash-menu/types.ts b/apps/client/src/features/editor/components/slash-menu/types.ts index 2bd9a9f3..c650b605 100644 --- a/apps/client/src/features/editor/components/slash-menu/types.ts +++ b/apps/client/src/features/editor/components/slash-menu/types.ts @@ -24,6 +24,11 @@ export type SlashMenuItemType = { // When true, the item is only offered to workspace admins/owners. This is a // UI convenience only — the real authoring gate is enforced server-side. adminOnly?: boolean; + // 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; }; export type SlashMenuGroupedItemsType = { diff --git a/apps/client/src/features/workspace/components/settings/components/html-embed-settings.tsx b/apps/client/src/features/workspace/components/settings/components/html-embed-settings.tsx new file mode 100644 index 00000000..27a21a8e --- /dev/null +++ b/apps/client/src/features/workspace/components/settings/components/html-embed-settings.tsx @@ -0,0 +1,99 @@ +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 { Switch, Stack, Paper, Group, Text, List } from "@mantine/core"; +import { notifications } from "@mantine/notifications"; +import useUserRole from "@/hooks/use-user-role.tsx"; +import { useTranslation } from "react-i18next"; + +/** + * Admin toggle for the workspace HTML embed feature. + * + * SECURITY: when ON, workspace admins/owners can embed raw HTML/CSS/JS that + * EXECUTES in the wiki page origin for every reader (a deliberate stored-XSS + * surface, e.g. for analytics trackers). OFF by default. The server strips + * htmlEmbed nodes on every write where the toggle is OFF or the saver is not an + * admin, so this switch fully enables/disables the feature workspace-wide. + */ +export default function HtmlEmbedSettings() { + const { t } = useTranslation(); + const [workspace, setWorkspace] = useAtom(workspaceAtom); + const { isAdmin } = useUserRole(); + + const [checked, setChecked] = useState( + workspace?.settings?.htmlEmbed ?? false, + ); + const [isLoading, setIsLoading] = useState(false); + + async function handleToggle(value: boolean) { + setIsLoading(true); + const previous = checked; + setChecked(value); // optimistic update + try { + const updated = await updateWorkspace({ htmlEmbed: value }); + // Force settings.htmlEmbed to the new value so the atom is consistent even + // if the response shape omits it. + setWorkspace({ + ...updated, + settings: { + ...updated.settings, + htmlEmbed: value, + }, + }); + notifications.show({ message: t("Updated successfully") }); + } catch (err) { + console.log(err); + setChecked(previous); // revert on failure + notifications.show({ + message: t("Failed to update data"), + color: "red", + }); + } finally { + setIsLoading(false); + } + } + + return ( + + + + {t("HTML embed")} + + + {t("advanced")} + + + + + handleToggle(event.currentTarget.checked)} + /> + + + + {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.", + )} + + + {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.", + )} + + + {t( + "Turning this off strips existing embeds on their next save and immediately disables execution (existing embeds render as a disabled placeholder).", + )} + + + + + ); +} diff --git a/apps/client/src/features/workspace/types/workspace.types.ts b/apps/client/src/features/workspace/types/workspace.types.ts index 9a44ed8d..52a58469 100644 --- a/apps/client/src/features/workspace/types/workspace.types.ts +++ b/apps/client/src/features/workspace/types/workspace.types.ts @@ -29,6 +29,9 @@ export interface IWorkspace { restrictApiToAdmins?: boolean; allowMemberTemplates?: boolean; isScimEnabled?: boolean; + // Write-only field for updateWorkspace({ htmlEmbed }). Read state lives at + // settings.htmlEmbed. + htmlEmbed?: boolean; } export interface IWorkspaceSettings { @@ -36,6 +39,8 @@ export interface IWorkspaceSettings { sharing?: IWorkspaceSharingSettings; api?: IWorkspaceApiSettings; templates?: IWorkspaceTemplateSettings; + // Admin-only HTML embed feature toggle. ABSENT/false => OFF (default). + htmlEmbed?: boolean; } export interface IWorkspaceApiSettings { diff --git a/apps/client/src/pages/settings/workspace/workspace-settings.tsx b/apps/client/src/pages/settings/workspace/workspace-settings.tsx index bb759a9b..484ad860 100644 --- a/apps/client/src/pages/settings/workspace/workspace-settings.tsx +++ b/apps/client/src/pages/settings/workspace/workspace-settings.tsx @@ -1,6 +1,7 @@ import SettingsTitle from "@/components/settings/settings-title.tsx"; import WorkspaceNameForm from "@/features/workspace/components/settings/components/workspace-name-form"; import WorkspaceIcon from "@/features/workspace/components/settings/components/workspace-icon.tsx"; +import HtmlEmbedSettings from "@/features/workspace/components/settings/components/html-embed-settings.tsx"; import { useTranslation } from "react-i18next"; import { getAppName } from "@/lib/config.ts"; import { Helmet } from "react-helmet-async"; @@ -15,6 +16,7 @@ export default function WorkspaceSettings() { + ); } diff --git a/apps/server/src/collaboration/collaboration.handler.html-embed.spec.ts b/apps/server/src/collaboration/collaboration.handler.html-embed.spec.ts index 4431fa67..23b11ba9 100644 --- a/apps/server/src/collaboration/collaboration.handler.html-embed.spec.ts +++ b/apps/server/src/collaboration/collaboration.handler.html-embed.spec.ts @@ -48,8 +48,18 @@ const docWithEmbed = () => ({ * TiptapTransformer.toYdoc() and applies it to the doc, so decoding * the doc afterward yields exactly the gated content. */ -async function gatedContentFor(role: string | null | undefined) { - const handler = new CollaborationHandler(); +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 @@ -70,7 +80,7 @@ async function gatedContentFor(role: string | null | undefined) { await handlers.updatePageContent('page-1', { prosemirrorJson: docWithEmbed(), operation: 'replace', - user: { id: 'u1', role } as any, + user: { id: 'u1', role, workspaceId: 'ws-1' } as any, }); return TiptapTransformer.fromYdoc(captureDoc, 'default'); @@ -92,11 +102,19 @@ describe('CollaborationHandler.updatePageContent htmlEmbed admin gate (real code } }); - it('admin: htmlEmbed preserved', async () => { - expect(hasHtmlEmbedNode(await gatedContentFor('admin'))).toBe(true); + it('toggle ON + admin: htmlEmbed preserved', async () => { + expect(hasHtmlEmbedNode(await gatedContentFor('admin', true))).toBe(true); }); - it('owner: htmlEmbed preserved', async () => { - expect(hasHtmlEmbedNode(await gatedContentFor('owner'))).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); }); }); diff --git a/apps/server/src/collaboration/collaboration.handler.ts b/apps/server/src/collaboration/collaboration.handler.ts index fae7935d..debfde60 100644 --- a/apps/server/src/collaboration/collaboration.handler.ts +++ b/apps/server/src/collaboration/collaboration.handler.ts @@ -9,10 +9,12 @@ import { setYjsMark, updateYjsMarkAttribute, YjsSelection } from './yjs.util'; import * as Y from 'yjs'; import { User } from '@docmost/db/types/entity.types'; import { - canAuthorHtmlEmbed, hasHtmlEmbedNode, + htmlEmbedAllowed, + isHtmlEmbedFeatureEnabled, stripHtmlEmbedNodes, } from '../common/helpers/prosemirror/html-embed.util'; +import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; export type CollabEventHandlers = ReturnType< CollaborationHandler['getHandlers'] @@ -22,7 +24,7 @@ export type CollabEventHandlers = ReturnType< export class CollaborationHandler { private readonly logger = new Logger(CollaborationHandler.name); - constructor() {} + constructor(private readonly workspaceRepo: WorkspaceRepo) {} getHandlers(hocuspocus: Hocuspocus) { return { @@ -98,10 +100,16 @@ export class CollaborationHandler { // 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. - if (!canAuthorHtmlEmbed(user?.role)) { + // 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 non-admin update by user ${user?.id} on ${documentName}`, + `Stripping htmlEmbed node(s) from update by user ${user?.id} on ${documentName}`, ); prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson); } diff --git a/apps/server/src/collaboration/extensions/persistence.extension.html-embed.spec.ts b/apps/server/src/collaboration/extensions/persistence.extension.html-embed.spec.ts index 79f2dcbc..a78173a6 100644 --- a/apps/server/src/collaboration/extensions/persistence.extension.html-embed.spec.ts +++ b/apps/server/src/collaboration/extensions/persistence.extension.html-embed.spec.ts @@ -121,7 +121,7 @@ function nodeTypeCounts(json: any): Record { * onStoreDocument to reach the strip + persist branch, and capture the content * that would be written to the page row. */ -function buildExtension() { +function buildExtension(featureEnabled = true) { const captured: { content?: any } = {}; const existingPage = { @@ -159,6 +159,14 @@ function buildExtension() { 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, @@ -168,13 +176,18 @@ function buildExtension() { noopQueue, collabHistory, transclusionService, + workspaceRepo as any, ); return { ext, captured, pageRepo }; } -async function runStore(role: string | null | undefined, doc: Y.Doc) { - const { ext, captured } = buildExtension(); +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; @@ -216,18 +229,33 @@ describe('PersistenceExtension.onStoreDocument htmlEmbed admin gate (real code)' expect(hasHtmlEmbedNode(reDecoded)).toBe(false); }); - it('admin store: htmlEmbed preserved in persisted content', async () => { - const captured = await runStore('admin', buildYdoc(RICH_DOC)); + 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('owner store: htmlEmbed preserved', async () => { - const captured = await runStore('owner', buildYdoc(RICH_DOC)); + 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), diff --git a/apps/server/src/collaboration/extensions/persistence.extension.ts b/apps/server/src/collaboration/extensions/persistence.extension.ts index b9376d30..91331dec 100644 --- a/apps/server/src/collaboration/extensions/persistence.extension.ts +++ b/apps/server/src/collaboration/extensions/persistence.extension.ts @@ -40,10 +40,12 @@ import { } from '../constants'; import { TransclusionService } from '../../core/page/transclusion/transclusion.service'; import { - canAuthorHtmlEmbed, hasHtmlEmbedNode, + htmlEmbedAllowed, + isHtmlEmbedFeatureEnabled, stripHtmlEmbedNodes, } from '../../common/helpers/prosemirror/html-embed.util'; +import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; @Injectable() export class PersistenceExtension implements Extension { @@ -64,6 +66,7 @@ export class PersistenceExtension implements Extension { @InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue, private readonly collabHistory: CollabHistoryService, private readonly transclusionService: TransclusionService, + private readonly workspaceRepo: WorkspaceRepo, ) {} async onLoadDocument(data: onLoadDocumentPayload) { @@ -146,10 +149,16 @@ export class PersistenceExtension implements Extension { // 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. - if (!canAuthorHtmlEmbed(context?.user?.role)) { + // 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 non-admin collab store by user ${context?.user?.id} on ${documentName}`, + `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 diff --git a/apps/server/src/common/helpers/prosemirror/html-embed.spec.ts b/apps/server/src/common/helpers/prosemirror/html-embed.spec.ts index 4bedc075..6b07ec0b 100644 --- a/apps/server/src/common/helpers/prosemirror/html-embed.spec.ts +++ b/apps/server/src/common/helpers/prosemirror/html-embed.spec.ts @@ -1,6 +1,8 @@ import { canAuthorHtmlEmbed, hasHtmlEmbedNode, + htmlEmbedAllowed, + isHtmlEmbedFeatureEnabled, stripHtmlEmbedNodes, } from './html-embed.util'; import { htmlToJson, jsonToHtml } from '../../../collaboration/collaboration.util'; @@ -105,6 +107,40 @@ describe('canAuthorHtmlEmbed', () => { }); }); +describe('isHtmlEmbedFeatureEnabled', () => { + it('is true only when settings.htmlEmbed === true', () => { + expect(isHtmlEmbedFeatureEnabled({ htmlEmbed: true })).toBe(true); + }); + it('defaults to false (absent / false / non-object)', () => { + expect(isHtmlEmbedFeatureEnabled({})).toBe(false); + expect(isHtmlEmbedFeatureEnabled({ htmlEmbed: false })).toBe(false); + expect(isHtmlEmbedFeatureEnabled(null)).toBe(false); + expect(isHtmlEmbedFeatureEnabled(undefined)).toBe(false); + // Truthy-but-not-true values must NOT enable the feature. + expect(isHtmlEmbedFeatureEnabled({ htmlEmbed: 'true' as any })).toBe(false); + }); +}); + +describe('htmlEmbedAllowed (toggle AND admin)', () => { + it('toggle OFF + admin/owner => not allowed (feature disabled for everyone)', () => { + expect(htmlEmbedAllowed(false, 'admin')).toBe(false); + expect(htmlEmbedAllowed(false, 'owner')).toBe(false); + }); + 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 diff --git a/apps/server/src/common/helpers/prosemirror/html-embed.util.ts b/apps/server/src/common/helpers/prosemirror/html-embed.util.ts index 5a521ba5..f1d0b6e5 100644 --- a/apps/server/src/common/helpers/prosemirror/html-embed.util.ts +++ b/apps/server/src/common/helpers/prosemirror/html-embed.util.ts @@ -66,3 +66,37 @@ export function hasHtmlEmbedNode(pmJson: unknown): boolean { 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( + settings: unknown | null | undefined, +): boolean { + if (!settings || typeof settings !== 'object') { + return false; + } + return (settings as Record).htmlEmbed === true; +} diff --git a/apps/server/src/core/page/services/page-service-html-embed-identity.spec.ts b/apps/server/src/core/page/services/page-service-html-embed-identity.spec.ts index 43361325..f23d565a 100644 --- a/apps/server/src/core/page/services/page-service-html-embed-identity.spec.ts +++ b/apps/server/src/core/page/services/page-service-html-embed-identity.spec.ts @@ -1,8 +1,8 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { - canAuthorHtmlEmbed, hasHtmlEmbedNode, + htmlEmbedAllowed, stripHtmlEmbedNodes, } from '../../../common/helpers/prosemirror/html-embed.util'; @@ -29,49 +29,74 @@ const docWithEmbed = () => ({ ], }); -// The real predicate both paths apply (see SECURITY blocks in page.service.ts). -function applyGate(json: any, role: string | null | undefined) { - if (!canAuthorHtmlEmbed(role) && hasHtmlEmbedNode(json)) { +// 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('non-admin (member) strips', () => { - const result = applyGate(docWithEmbed(), 'member'); + 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('unknown/empty role fails closed (strips)', () => { + it('toggle ON + unknown/empty role fails closed (strips)', () => { for (const role of [null, undefined, 'viewer'] as const) { - expect(hasHtmlEmbedNode(applyGate(docWithEmbed(), role))).toBe(false); + expect(hasHtmlEmbedNode(applyGate(docWithEmbed(), true, role))).toBe( + false, + ); } }); - it('admin/owner keep', () => { - expect(hasHtmlEmbedNode(applyGate(docWithEmbed(), 'admin'))).toBe(true); - expect(hasHtmlEmbedNode(applyGate(docWithEmbed(), 'owner'))).toBe(true); + 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 the caller role param before deriving content/ydoc', () => { + 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 - // it; the embed must be stripped BEFORE insertPage. + // the combined toggle-AND-admin predicate; the embed must be stripped BEFORE + // insertPage. expect(SRC).toMatch( - /!canAuthorHtmlEmbed\(\s*callerRole\s*\)\s*&&\s*hasHtmlEmbedNode\(\s*prosemirrorJson\s*\)/, + /!htmlEmbedAllowed\(\s*htmlEmbedEnabled\s*,\s*callerRole\s*\)\s*&&\s*hasHtmlEmbedNode\(\s*prosemirrorJson\s*\)/, ); expect(SRC).toContain('prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson)'); }); - it('duplicatePage() gates on the duplicating user role (authUser.role)', () => { + it('duplicatePage() gates on toggle AND the duplicating user role (authUser.role)', () => { expect(SRC).toMatch( - /!canAuthorHtmlEmbed\(\s*authUser\.role\s*\)\s*&&\s*hasHtmlEmbedNode\(\s*prosemirrorJson\s*\)/, + /!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('); + }); }); diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index baf5506a..2c3661f6 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -31,10 +31,12 @@ import { removeMarkTypeFromDoc, } from '../../../common/helpers/prosemirror/utils'; import { - canAuthorHtmlEmbed, hasHtmlEmbedNode, + htmlEmbedAllowed, + isHtmlEmbedFeatureEnabled, stripHtmlEmbedNodes, } from '../../../common/helpers/prosemirror/html-embed.util'; +import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; import { htmlToJson, jsonToNode, @@ -79,6 +81,7 @@ export class PageService { private collaborationGateway: CollaborationGateway, private readonly watcherService: WatcherService, private readonly transclusionService: TransclusionService, + private readonly workspaceRepo: WorkspaceRepo, ) {} async findById( @@ -146,9 +149,18 @@ export class PageService { // 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. - if (!canAuthorHtmlEmbed(callerRole) && hasHtmlEmbedNode(prosemirrorJson)) { + // 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 non-admin page creation by user ${userId} (space ${createPageDto.spaceId})`, + `Stripping htmlEmbed node(s) from page creation by user ${userId} (space ${createPageDto.spaceId})`, ); prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson); } @@ -614,6 +626,12 @@ export class PageService { const attachmentMap = new Map(); + // 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( pages.map(async (page) => { const pageContent = getProsemirrorContent(page.content); @@ -724,11 +742,11 @@ export class PageService { // htmlEmbed node from each duplicated page when the duplicating user is // not an admin, BEFORE computing textContent/ydoc/insert. if ( - !canAuthorHtmlEmbed(authUser.role) && + !htmlEmbedAllowed(htmlEmbedEnabled, authUser.role) && hasHtmlEmbedNode(prosemirrorJson) ) { this.logger.warn( - `Stripping htmlEmbed node(s) from non-admin page duplication by user ${authUser.id} (source page ${page.id})`, + `Stripping htmlEmbed node(s) from page duplication by user ${authUser.id} (source page ${page.id})`, ); prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson); } diff --git a/apps/server/src/core/page/transclusion/spec/transclusion-unsync-html-embed.spec.ts b/apps/server/src/core/page/transclusion/spec/transclusion-unsync-html-embed.spec.ts index dc179d33..8ad13121 100644 --- a/apps/server/src/core/page/transclusion/spec/transclusion-unsync-html-embed.spec.ts +++ b/apps/server/src/core/page/transclusion/spec/transclusion-unsync-html-embed.spec.ts @@ -21,7 +21,7 @@ const sourceContentWithEmbed = () => ({ ], }); -function buildService() { +function buildService(featureEnabled = true) { const pageRepo = { findById: jest.fn(async (id: string) => ({ id, @@ -44,6 +44,13 @@ function buildService() { 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) @@ -55,6 +62,7 @@ function buildService() { attachmentRepo as any, storageService as any, pageAccessService as any, + workspaceRepo as any, ); return service; } @@ -90,8 +98,8 @@ describe('TransclusionService.unsyncReference htmlEmbed admin gate (real code)', } }); - it('admin: returned content keeps the htmlEmbed', async () => { - const service = buildService(); + it('toggle ON + admin: returned content keeps the htmlEmbed', async () => { + const service = buildService(true); const { content } = await service.unsyncReference( REF_PAGE, SRC_PAGE, @@ -101,8 +109,8 @@ describe('TransclusionService.unsyncReference htmlEmbed admin gate (real code)', expect(hasHtmlEmbedNode(content)).toBe(true); }); - it('owner: returned content keeps the htmlEmbed', async () => { - const service = buildService(); + it('toggle ON + owner: returned content keeps the htmlEmbed', async () => { + const service = buildService(true); const { content } = await service.unsyncReference( REF_PAGE, SRC_PAGE, @@ -111,4 +119,26 @@ describe('TransclusionService.unsyncReference htmlEmbed admin gate (real code)', ); 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); + }); }); diff --git a/apps/server/src/core/page/transclusion/transclusion.service.ts b/apps/server/src/core/page/transclusion/transclusion.service.ts index 73abb49b..2fd92402 100644 --- a/apps/server/src/core/page/transclusion/transclusion.service.ts +++ b/apps/server/src/core/page/transclusion/transclusion.service.ts @@ -24,10 +24,12 @@ import { TransclusionLookup } from './transclusion.types'; import { Page, User } from '@docmost/db/types/entity.types'; import { PageAccessService } from '../page-access/page-access.service'; import { - canAuthorHtmlEmbed, hasHtmlEmbedNode, + htmlEmbedAllowed, + isHtmlEmbedFeatureEnabled, stripHtmlEmbedNodes, } from '../../../common/helpers/prosemirror/html-embed.util'; +import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; type ReferencingPageInfo = { id: string; @@ -52,6 +54,7 @@ export class TransclusionService { private readonly attachmentRepo: AttachmentRepo, private readonly storageService: StorageService, private readonly pageAccessService: PageAccessService, + private readonly workspaceRepo: WorkspaceRepo, ) {} async syncPageTransclusions( @@ -528,9 +531,12 @@ export class TransclusionService { // 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. - if (!canAuthorHtmlEmbed(user.role) && hasHtmlEmbedNode(content)) { + 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 non-admin transclusion unsync by user ${user.id} (reference page ${referencePageId}, source page ${sourcePageId})`, + `Stripping htmlEmbed node(s) from transclusion unsync by user ${user.id} (reference page ${referencePageId}, source page ${sourcePageId})`, ); content = stripHtmlEmbedNodes(content); } diff --git a/apps/server/src/core/workspace/dto/update-workspace.dto.ts b/apps/server/src/core/workspace/dto/update-workspace.dto.ts index 08ba967d..59a6495a 100644 --- a/apps/server/src/core/workspace/dto/update-workspace.dto.ts +++ b/apps/server/src/core/workspace/dto/update-workspace.dto.ts @@ -53,6 +53,12 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) { @IsBoolean() aiDictation: boolean; + // Workspace feature toggle for the admin-only HTML embed feature. Persisted at + // settings.htmlEmbed. ABSENT/false => OFF (default). + @IsOptional() + @IsBoolean() + htmlEmbed: boolean; + @IsOptional() @IsInt() @Min(1) diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index ec419fba..11c0d6b5 100644 --- a/apps/server/src/core/workspace/services/workspace.service.ts +++ b/apps/server/src/core/workspace/services/workspace.service.ts @@ -511,6 +511,20 @@ export class WorkspaceService { ); } + if (typeof updateWorkspaceDto.htmlEmbed !== 'undefined') { + const prev = settingsBefore?.htmlEmbed ?? false; + if (prev !== updateWorkspaceDto.htmlEmbed) { + before.htmlEmbed = prev; + after.htmlEmbed = updateWorkspaceDto.htmlEmbed; + } + await this.workspaceRepo.updateSetting( + workspaceId, + 'htmlEmbed', + updateWorkspaceDto.htmlEmbed, + trx, + ); + } + delete updateWorkspaceDto.restrictApiToAdmins; delete updateWorkspaceDto.aiSearch; delete updateWorkspaceDto.generativeAi; @@ -519,6 +533,7 @@ export class WorkspaceService { delete updateWorkspaceDto.allowMemberTemplates; delete updateWorkspaceDto.aiChat; delete updateWorkspaceDto.aiDictation; + delete updateWorkspaceDto.htmlEmbed; await this.workspaceRepo.updateWorkspace( updateWorkspaceDto, diff --git a/apps/server/src/database/repos/workspace/workspace.repo.ts b/apps/server/src/database/repos/workspace/workspace.repo.ts index b5d62f7a..d5d3dc82 100644 --- a/apps/server/src/database/repos/workspace/workspace.repo.ts +++ b/apps/server/src/database/repos/workspace/workspace.repo.ts @@ -265,6 +265,32 @@ export class WorkspaceRepo { .executeTakeFirst(); } + /** + * Set a single scalar key at the TOP LEVEL of `settings` (e.g. + * `settings.htmlEmbed`). Mirrors `updateAiSettings`/`updateSharingSettings` + * but without a nested namespace object. `prefKey` comes from a fixed + * allowlist at the call site (inlined via `sql.raw`, never user input); the + * value is inlined via `sql.lit`. + */ + async updateSetting( + workspaceId: string, + prefKey: string, + prefValue: string | boolean, + trx?: KyselyTransaction, + ) { + const db = dbOrTx(this.db, trx); + return db + .updateTable('workspaces') + .set({ + settings: sql`COALESCE(settings, '{}'::jsonb) + || jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)})`, + updatedAt: new Date(), + }) + .where('id', '=', workspaceId) + .returning(this.baseFields) + .executeTakeFirst(); + } + async updateSharingSettings( workspaceId: string, prefKey: string, diff --git a/apps/server/src/integrations/import/services/file-import-task.service.ts b/apps/server/src/integrations/import/services/file-import-task.service.ts index fe063597..5e19845c 100644 --- a/apps/server/src/integrations/import/services/file-import-task.service.ts +++ b/apps/server/src/integrations/import/services/file-import-task.service.ts @@ -21,11 +21,13 @@ import { FileTask, InsertablePage } from '@docmost/db/types/entity.types'; import { markdownToHtml } from '@docmost/editor-ext'; import { getProsemirrorContent } from '../../../common/helpers/prosemirror/utils'; import { - canAuthorHtmlEmbed, 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 { buildAttachmentCandidates, @@ -60,6 +62,7 @@ export class FileImportTaskService { @InjectKysely() private readonly db: KyselyDB, private readonly importAttachmentService: ImportAttachmentService, private readonly userRepo: UserRepo, + private readonly workspaceRepo: WorkspaceRepo, private eventEmitter: EventEmitter2, @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, ) {} @@ -168,7 +171,16 @@ export class FileImportTaskService { fileTask.creatorId, fileTask.workspaceId, ); - const importerCanAuthorHtmlEmbed = canAuthorHtmlEmbed(importingUser?.role); + // 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(); diff --git a/apps/server/src/integrations/import/services/import-html-embed-identity.spec.ts b/apps/server/src/integrations/import/services/import-html-embed-identity.spec.ts index 003208d8..603765fc 100644 --- a/apps/server/src/integrations/import/services/import-html-embed-identity.spec.ts +++ b/apps/server/src/integrations/import/services/import-html-embed-identity.spec.ts @@ -1,8 +1,8 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { - canAuthorHtmlEmbed, hasHtmlEmbedNode, + htmlEmbedAllowed, stripHtmlEmbedNodes, } from '../../../common/helpers/prosemirror/html-embed.util'; @@ -36,39 +36,53 @@ const docWithEmbed = () => ({ // 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, importingUser: { role?: string } | undefined) { - if (!canAuthorHtmlEmbed(importingUser?.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 resolved-user role (real helpers)', () => { - it('missing user (userRepo.findById -> undefined) strips the embed', () => { +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 -> canAuthorHtmlEmbed(undefined) === false. - const importingUser = undefined; - const result = applyImportGate(docWithEmbed(), importingUser); + // undefined?.role is undefined -> htmlEmbedAllowed(true, undefined) === false. + const result = applyImportGate(docWithEmbed(), true, undefined); expect(hasHtmlEmbedNode(result)).toBe(false); }); - it("resolved role 'member' strips", () => { + it("toggle ON + resolved role 'member' strips", () => { expect( - hasHtmlEmbedNode(applyImportGate(docWithEmbed(), { role: 'member' })), + hasHtmlEmbedNode(applyImportGate(docWithEmbed(), true, { role: 'member' })), ).toBe(false); }); - it("resolved role 'admin' keeps the embed", () => { + it("toggle ON + resolved role 'admin' keeps the embed", () => { expect( - hasHtmlEmbedNode(applyImportGate(docWithEmbed(), { role: 'admin' })), + hasHtmlEmbedNode(applyImportGate(docWithEmbed(), true, { role: 'admin' })), ).toBe(true); }); - it("resolved role 'owner' keeps the embed", () => { + it("toggle ON + resolved role 'owner' keeps the embed", () => { expect( - hasHtmlEmbedNode(applyImportGate(docWithEmbed(), { role: 'owner' })), + 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 @@ -83,7 +97,11 @@ describe('import gate identity is pinned to the importer (source contract)', () expect(src).toMatch( /this\.userRepo\.findById\(\s*userId\s*,\s*workspaceId\s*\)/, ); - expect(src).toMatch(/canAuthorHtmlEmbed\(\s*importingUser\?\.role\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)'); }); @@ -97,8 +115,9 @@ describe('import gate identity is pinned to the importer (source contract)', () /this\.userRepo\.findById\(\s*fileTask\.creatorId\s*,\s*fileTask\.workspaceId\s*,?\s*\)/, ); expect(src).toMatch( - /importerCanAuthorHtmlEmbed\s*=\s*canAuthorHtmlEmbed\(\s*importingUser\?\.role\s*\)/, + /importerCanAuthorHtmlEmbed\s*=\s*htmlEmbedAllowed\(\s*htmlEmbedEnabled\s*,\s*importingUser\?\.role\s*,?\s*\)/, ); + expect(src).toContain('isHtmlEmbedFeatureEnabled('); expect(src).toContain('stripHtmlEmbedNodes(prosemirrorJson)'); }); }); diff --git a/apps/server/src/integrations/import/services/import.service.ts b/apps/server/src/integrations/import/services/import.service.ts index 46bd6ed2..574a13ab 100644 --- a/apps/server/src/integrations/import/services/import.service.ts +++ b/apps/server/src/integrations/import/services/import.service.ts @@ -2,10 +2,12 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { - canAuthorHtmlEmbed, 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 * as path from 'path'; import { @@ -48,6 +50,7 @@ export class ImportService { @InjectKysely() private readonly db: KyselyDB, @InjectQueue(QueueName.FILE_TASK_QUEUE) private readonly fileTaskQueue: Queue, + private readonly workspaceRepo: WorkspaceRepo, ) {} async importPage( @@ -101,9 +104,15 @@ export class ImportService { // imports performed by a non-admin user. if (prosemirrorJson && hasHtmlEmbedNode(prosemirrorJson)) { const importingUser = await this.userRepo.findById(userId, workspaceId); - if (!canAuthorHtmlEmbed(importingUser?.role)) { + // 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 non-admin import by user ${userId}`, + `Stripping htmlEmbed node(s) from import by user ${userId}`, ); prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson); } diff --git a/docs/html-embed-admin.md b/docs/html-embed-admin.md new file mode 100644 index 00000000..39b8e674 --- /dev/null +++ b/docs/html-embed-admin.md @@ -0,0 +1,75 @@ +# HTML embed (admin-only) — workspace feature toggle + +The `htmlEmbed` node lets a workspace **admin/owner** embed raw HTML/CSS/JS that +**executes in the wiki page origin** for everyone who views the page. This is a +deliberate stored-XSS surface (e.g. for analytics trackers / third-party +widgets). It is gated behind a per-workspace feature toggle that is **OFF by +default**. + +## Behavior + +- **OFF by default.** A fresh/unconfigured workspace has the feature disabled + (`settings.htmlEmbed` absent or `false`). With the toggle OFF, htmlEmbed is + stripped on every save for **everyone, including admins** — the feature is + fully disabled. +- **Only admins/owners can insert.** When the toggle is ON, the `/html` slash + item (and the embed editor) is offered only to workspace admins/owners. + Members never see it. The gate is **toggle AND admin**. +- **Server strips on every write path (fail-closed).** The UI gate is a + convenience only. The server independently strips htmlEmbed nodes from every + write where the gate is not satisfied. If a **non-admin** edits and saves a + page that contains an admin's embed, that save **strips the embed** — the + admin must re-add it. Same if the toggle is OFF. +- **Turning the toggle OFF neutralizes existing embeds.** Existing embeds are + stripped on their next save (collab store / REST / etc.), and the client + NodeView stops executing them immediately, rendering a disabled placeholder + instead (defense in depth at render time). + +## Storage + +The toggle lives in the workspace `settings` jsonb at the top level: +`settings.htmlEmbed` (boolean). ABSENT/`false` => OFF. + +- Update field: `htmlEmbed: boolean` on `UpdateWorkspaceDto`. +- Persisted by `WorkspaceService.update` via + `WorkspaceRepo.updateSetting(workspaceId, 'htmlEmbed', value)` (top-level + scalar settings key; analogous to `updateAiSettings`). The change is + audit-logged like the AI toggles. + +## Server gate + +`apps/server/src/common/helpers/prosemirror/html-embed.util.ts`: + +```ts +// Allowed only when the workspace feature toggle is ON and the user is admin/owner. +export function htmlEmbedAllowed(featureEnabled: boolean, role): boolean { + return featureEnabled === true && canAuthorHtmlEmbed(role); +} +// settings.htmlEmbed === true (ABSENT/non-true => OFF). +export function isHtmlEmbedFeatureEnabled(settings): boolean { ... } +``` + +Every write-path gate site reads the workspace's setting +(`workspace.settings?.htmlEmbed === true`, via `WorkspaceRepo.findById`) and +applies `!htmlEmbedAllowed(featureEnabled, role)` before persisting. The 7 sites: + +- `core/page/services/page.service.ts` — `create()` and `duplicatePage()` +- `collaboration/extensions/persistence.extension.ts` — collab store +- `collaboration/collaboration.handler.ts` — REST/MCP/AI content update +- `integrations/import/services/import.service.ts` — single import +- `integrations/import/services/file-import-task.service.ts` — zip import +- `core/page/transclusion/transclusion.service.ts` — transclusion unsync + +## Client + +- Slash menu: the `/html` item carries `requiresHtmlEmbedFeature: true` and + `adminOnly: true`; it is hidden unless the persisted + `workspace.settings.htmlEmbed === true` AND the user is admin. The slash + function reads the toggle from the persisted `currentUser` localStorage entry + (same mechanism as `isCurrentUserAdmin()`). +- NodeView (`html-embed-view.tsx`): only executes the raw HTML/JS when the + toggle is ON; otherwise renders a neutral "HTML embed is disabled in this + workspace" placeholder and injects nothing. +- Admin UI: a Switch in **Workspace Settings → General** (`HtmlEmbedSettings`) + toggles the feature with an optimistic `updateWorkspace({ htmlEmbed })`, with a + description documenting the security implications.