feat(html-embed): per-workspace feature toggle, default OFF
The admin-only raw HTML/JS embed is a deliberate stored-XSS surface, so gate the whole feature behind a workspace toggle that is OFF by default; it only works when a workspace admin explicitly enables it. - settings.htmlEmbed (boolean, default false) + workspace-update field htmlEmbed, persisted via WorkspaceRepo.updateSetting with an audit diff. Flipping it is admin-only (same Manage Settings CASL as other workspace toggles). - New gate htmlEmbedAllowed(featureEnabled, role) = featureEnabled && admin/owner. All 7 server write paths (create, duplicate, collab onStoreDocument, REST/MCP/AI updatePageContent, single + zip import, transclusion unsync) now read the workspace's settings.htmlEmbed and strip unless (toggle ON AND admin). OFF (default, or a failed/empty workspace lookup) strips htmlEmbed for EVERYONE including admins -> existing embeds are cleaned up on next save, none persist. - Client (defense-in-depth): the /html slash item is hidden unless toggle ON + admin; the NodeView executes nothing and shows a 'disabled in this workspace' placeholder when OFF; an admin Switch in Workspace Settings -> General with a description of the behavior. - docs/html-embed-admin.md documents the toggle + admin-only + fail-closed coedit (a non-admin save strips an admin's embed) + execution semantics. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,9 @@ import {
|
|||||||
} 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 useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import classes from "./html-embed-view.module.css";
|
import 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 { source } = node.attrs as { source: string };
|
||||||
const { isAdmin } = useUserRole();
|
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<HTMLDivElement | null>(null);
|
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [draft, setDraft] = useState<string>(source || "");
|
const [draft, setDraft] = useState<string>(source || "");
|
||||||
|
|
||||||
// (Re)render the raw source whenever it changes. This runs in BOTH the
|
// (Re)render the raw source whenever it changes. This runs in BOTH the
|
||||||
// editable editor and the read-only / public-share editor (same NodeView),
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (contentRef.current) {
|
if (!contentRef.current) return;
|
||||||
|
if (htmlEmbedEnabled) {
|
||||||
renderRawHtml(contentRef.current, source || "");
|
renderRawHtml(contentRef.current, source || "");
|
||||||
|
} else {
|
||||||
|
contentRef.current.innerHTML = "";
|
||||||
}
|
}
|
||||||
}, [source]);
|
}, [source, htmlEmbedEnabled]);
|
||||||
|
|
||||||
const openEditor = useCallback(() => {
|
const openEditor = useCallback(() => {
|
||||||
setDraft(source || "");
|
setDraft(source || "");
|
||||||
@@ -78,9 +91,10 @@ export default function HtmlEmbedView(props: NodeViewProps) {
|
|||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
}, [draft, editor.isEditable, updateAttributes]);
|
}, [draft, editor.isEditable, updateAttributes]);
|
||||||
|
|
||||||
// The edit affordance is only meaningful in edit mode, and authoring is
|
// The edit affordance is only meaningful in edit mode, is restricted to admins
|
||||||
// restricted to admins (the server strips the node for non-admins anyway).
|
// (the server strips the node for non-admins anyway), and is offered only when
|
||||||
const canEdit = editor.isEditable && isAdmin;
|
// the workspace feature toggle is ON.
|
||||||
|
const canEdit = editor.isEditable && isAdmin && htmlEmbedEnabled;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper
|
<NodeViewWrapper
|
||||||
@@ -102,7 +116,16 @@ export default function HtmlEmbedView(props: NodeViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{source ? (
|
{!htmlEmbedEnabled ? (
|
||||||
|
// Feature disabled for this workspace: never inject/execute the source.
|
||||||
|
// Show a neutral placeholder so an existing embed is visibly inert.
|
||||||
|
<div className={classes.htmlEmbedPlaceholder}>
|
||||||
|
<IconCode size={18} />
|
||||||
|
<Text size="sm">
|
||||||
|
{t("HTML embed is disabled in this workspace")}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
) : source ? (
|
||||||
// Raw HTML/CSS/JS rendered into the wiki origin. Scripts are re-created
|
// Raw HTML/CSS/JS rendered into the wiki origin. Scripts are re-created
|
||||||
// in renderRawHtml so they execute.
|
// in renderRawHtml so they execute.
|
||||||
<div ref={contentRef} className={classes.htmlEmbedContent} />
|
<div ref={contentRef} className={classes.htmlEmbedContent} />
|
||||||
|
|||||||
@@ -593,6 +593,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
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,
|
adminOnly: true,
|
||||||
|
requiresHtmlEmbedFeature: true,
|
||||||
command: ({ editor, range }: CommandProps) => {
|
command: ({ editor, range }: CommandProps) => {
|
||||||
editor
|
editor
|
||||||
.chain()
|
.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 = ({
|
export const getSuggestionItems = ({
|
||||||
query,
|
query,
|
||||||
excludeItems,
|
excludeItems,
|
||||||
@@ -787,6 +807,7 @@ export const getSuggestionItems = ({
|
|||||||
const search = query.toLowerCase();
|
const search = query.toLowerCase();
|
||||||
const filteredGroups: SlashMenuGroupedItemsType = {};
|
const filteredGroups: SlashMenuGroupedItemsType = {};
|
||||||
const isAdmin = isCurrentUserAdmin();
|
const isAdmin = isCurrentUserAdmin();
|
||||||
|
const htmlEmbedFeatureEnabled = isHtmlEmbedFeatureEnabled();
|
||||||
|
|
||||||
const fuzzyMatch = (query: string, target: string) => {
|
const fuzzyMatch = (query: string, target: string) => {
|
||||||
let queryIndex = 0;
|
let queryIndex = 0;
|
||||||
@@ -803,6 +824,9 @@ export const getSuggestionItems = ({
|
|||||||
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 admin-only items (raw HTML embed) from non-admins.
|
||||||
if (item.adminOnly && !isAdmin) return false;
|
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 (
|
return (
|
||||||
fuzzyMatch(search, item.title) ||
|
fuzzyMatch(search, item.title) ||
|
||||||
item.description.toLowerCase().includes(search) ||
|
item.description.toLowerCase().includes(search) ||
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ export type SlashMenuItemType = {
|
|||||||
// When true, the item is only offered to workspace admins/owners. This is a
|
// 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.
|
// UI convenience only — the real authoring gate is enforced server-side.
|
||||||
adminOnly?: boolean;
|
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 = {
|
export type SlashMenuGroupedItemsType = {
|
||||||
|
|||||||
@@ -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<boolean>(
|
||||||
|
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 (
|
||||||
|
<Stack mt="sm">
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Text fw={700} size="lg">
|
||||||
|
{t("HTML embed")}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||||
|
{t("advanced")}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="lg">
|
||||||
|
<Switch
|
||||||
|
label={t("Enable HTML embed")}
|
||||||
|
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.",
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
disabled={!isAdmin || isLoading}
|
||||||
|
onChange={(event) => handleToggle(event.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<List size="xs" c="dimmed" mt="md" spacing={4}>
|
||||||
|
<List.Item>
|
||||||
|
{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.",
|
||||||
|
)}
|
||||||
|
</List.Item>
|
||||||
|
<List.Item>
|
||||||
|
{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.",
|
||||||
|
)}
|
||||||
|
</List.Item>
|
||||||
|
<List.Item>
|
||||||
|
{t(
|
||||||
|
"Turning this off strips existing embeds on their next save and immediately disables execution (existing embeds render as a disabled placeholder).",
|
||||||
|
)}
|
||||||
|
</List.Item>
|
||||||
|
</List>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,6 +29,9 @@ export interface IWorkspace {
|
|||||||
restrictApiToAdmins?: boolean;
|
restrictApiToAdmins?: boolean;
|
||||||
allowMemberTemplates?: boolean;
|
allowMemberTemplates?: boolean;
|
||||||
isScimEnabled?: boolean;
|
isScimEnabled?: boolean;
|
||||||
|
// Write-only field for updateWorkspace({ htmlEmbed }). Read state lives at
|
||||||
|
// settings.htmlEmbed.
|
||||||
|
htmlEmbed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWorkspaceSettings {
|
export interface IWorkspaceSettings {
|
||||||
@@ -36,6 +39,8 @@ 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).
|
||||||
|
htmlEmbed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWorkspaceApiSettings {
|
export interface IWorkspaceApiSettings {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
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 { 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";
|
||||||
@@ -15,6 +16,7 @@ export default function WorkspaceSettings() {
|
|||||||
<SettingsTitle title={t("General")} />
|
<SettingsTitle title={t("General")} />
|
||||||
<WorkspaceIcon />
|
<WorkspaceIcon />
|
||||||
<WorkspaceNameForm />
|
<WorkspaceNameForm />
|
||||||
|
<HtmlEmbedSettings />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,8 +48,18 @@ const docWithEmbed = () => ({
|
|||||||
* TiptapTransformer.toYdoc(<gated json>) and applies it to the doc, so decoding
|
* TiptapTransformer.toYdoc(<gated json>) and applies it to the doc, so decoding
|
||||||
* the doc afterward yields exactly the gated content.
|
* the doc afterward yields exactly the gated content.
|
||||||
*/
|
*/
|
||||||
async function gatedContentFor(role: string | null | undefined) {
|
async function gatedContentFor(
|
||||||
const handler = new CollaborationHandler();
|
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();
|
const captureDoc = new Y.Doc();
|
||||||
|
|
||||||
jest
|
jest
|
||||||
@@ -70,7 +80,7 @@ async function gatedContentFor(role: string | null | undefined) {
|
|||||||
await handlers.updatePageContent('page-1', {
|
await handlers.updatePageContent('page-1', {
|
||||||
prosemirrorJson: docWithEmbed(),
|
prosemirrorJson: docWithEmbed(),
|
||||||
operation: 'replace',
|
operation: 'replace',
|
||||||
user: { id: 'u1', role } as any,
|
user: { id: 'u1', role, workspaceId: 'ws-1' } as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
return TiptapTransformer.fromYdoc(captureDoc, 'default');
|
return TiptapTransformer.fromYdoc(captureDoc, 'default');
|
||||||
@@ -92,11 +102,19 @@ describe('CollaborationHandler.updatePageContent htmlEmbed admin gate (real code
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('admin: htmlEmbed preserved', async () => {
|
it('toggle ON + admin: htmlEmbed preserved', async () => {
|
||||||
expect(hasHtmlEmbedNode(await gatedContentFor('admin'))).toBe(true);
|
expect(hasHtmlEmbedNode(await gatedContentFor('admin', true))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('owner: htmlEmbed preserved', async () => {
|
it('toggle ON + owner: htmlEmbed preserved', async () => {
|
||||||
expect(hasHtmlEmbedNode(await gatedContentFor('owner'))).toBe(true);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ 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 {
|
import {
|
||||||
canAuthorHtmlEmbed,
|
|
||||||
hasHtmlEmbedNode,
|
hasHtmlEmbedNode,
|
||||||
|
htmlEmbedAllowed,
|
||||||
|
isHtmlEmbedFeatureEnabled,
|
||||||
stripHtmlEmbedNodes,
|
stripHtmlEmbedNodes,
|
||||||
} from '../common/helpers/prosemirror/html-embed.util';
|
} 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']
|
||||||
@@ -22,7 +24,7 @@ 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() {}
|
constructor(private readonly workspaceRepo: WorkspaceRepo) {}
|
||||||
|
|
||||||
getHandlers(hocuspocus: Hocuspocus) {
|
getHandlers(hocuspocus: Hocuspocus) {
|
||||||
return {
|
return {
|
||||||
@@ -98,10 +100,16 @@ export class CollaborationHandler {
|
|||||||
// arbitrary JS in every reader's browser, so a NON-admin caller must not
|
// 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
|
// 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.
|
// 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)) {
|
if (hasHtmlEmbedNode(prosemirrorJson)) {
|
||||||
this.logger.warn(
|
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);
|
prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ function nodeTypeCounts(json: any): Record<string, number> {
|
|||||||
* onStoreDocument to reach the strip + persist branch, and capture the content
|
* onStoreDocument to reach the strip + persist branch, and capture the content
|
||||||
* that would be written to the page row.
|
* that would be written to the page row.
|
||||||
*/
|
*/
|
||||||
function buildExtension() {
|
function buildExtension(featureEnabled = true) {
|
||||||
const captured: { content?: any } = {};
|
const captured: { content?: any } = {};
|
||||||
|
|
||||||
const existingPage = {
|
const existingPage = {
|
||||||
@@ -159,6 +159,14 @@ function buildExtension() {
|
|||||||
syncPageReferences: jest.fn(async () => undefined),
|
syncPageReferences: jest.fn(async () => undefined),
|
||||||
} as any;
|
} 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(
|
const ext = new PersistenceExtension(
|
||||||
pageRepo as any,
|
pageRepo as any,
|
||||||
pageHistoryRepo as any,
|
pageHistoryRepo as any,
|
||||||
@@ -168,13 +176,18 @@ function buildExtension() {
|
|||||||
noopQueue,
|
noopQueue,
|
||||||
collabHistory,
|
collabHistory,
|
||||||
transclusionService,
|
transclusionService,
|
||||||
|
workspaceRepo as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { ext, captured, pageRepo };
|
return { ext, captured, pageRepo };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runStore(role: string | null | undefined, doc: Y.Doc) {
|
async function runStore(
|
||||||
const { ext, captured } = buildExtension();
|
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
|
// 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).
|
// none, so stub it (the post-persist broadcast is not under test here).
|
||||||
(doc as any).broadcastStateless = () => undefined;
|
(doc as any).broadcastStateless = () => undefined;
|
||||||
@@ -216,18 +229,33 @@ describe('PersistenceExtension.onStoreDocument htmlEmbed admin gate (real code)'
|
|||||||
expect(hasHtmlEmbedNode(reDecoded)).toBe(false);
|
expect(hasHtmlEmbedNode(reDecoded)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('admin store: htmlEmbed preserved in persisted content', async () => {
|
it('toggle ON + admin store: htmlEmbed preserved in persisted content', async () => {
|
||||||
const captured = await runStore('admin', buildYdoc(RICH_DOC));
|
const captured = await runStore('admin', buildYdoc(RICH_DOC), true);
|
||||||
expect(captured.content).toBeDefined();
|
expect(captured.content).toBeDefined();
|
||||||
expect(hasHtmlEmbedNode(captured.content)).toBe(true);
|
expect(hasHtmlEmbedNode(captured.content)).toBe(true);
|
||||||
expect(nodeTypeCounts(captured.content)[HTML_EMBED_NODE_NAME]).toBe(2);
|
expect(nodeTypeCounts(captured.content)[HTML_EMBED_NODE_NAME]).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('owner store: htmlEmbed preserved', async () => {
|
it('toggle ON + owner store: htmlEmbed preserved', async () => {
|
||||||
const captured = await runStore('owner', buildYdoc(RICH_DOC));
|
const captured = await runStore('owner', buildYdoc(RICH_DOC), true);
|
||||||
expect(hasHtmlEmbedNode(captured.content)).toBe(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 () => {
|
it('unknown/empty role: fails closed (stripped)', async () => {
|
||||||
expect(
|
expect(
|
||||||
hasHtmlEmbedNode((await runStore(undefined, buildYdoc(RICH_DOC))).content),
|
hasHtmlEmbedNode((await runStore(undefined, buildYdoc(RICH_DOC))).content),
|
||||||
|
|||||||
@@ -40,10 +40,12 @@ import {
|
|||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
|
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
|
||||||
import {
|
import {
|
||||||
canAuthorHtmlEmbed,
|
|
||||||
hasHtmlEmbedNode,
|
hasHtmlEmbedNode,
|
||||||
|
htmlEmbedAllowed,
|
||||||
|
isHtmlEmbedFeatureEnabled,
|
||||||
stripHtmlEmbedNodes,
|
stripHtmlEmbedNodes,
|
||||||
} from '../../common/helpers/prosemirror/html-embed.util';
|
} 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 {
|
||||||
@@ -64,6 +66,7 @@ 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) {
|
||||||
@@ -146,10 +149,16 @@ export class PersistenceExtension implements Extension {
|
|||||||
// page-editor), and the PERSISTED page row plus every share/readonly read
|
// page-editor), and the PERSISTED page row plus every share/readonly read
|
||||||
// path are protected by this strip. The window is therefore accepted rather
|
// path are protected by this strip. The window is therefore accepted rather
|
||||||
// than mitigated with an inbound beforeBroadcast strip.
|
// 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)) {
|
if (hasHtmlEmbedNode(tiptapJson)) {
|
||||||
this.logger.warn(
|
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);
|
tiptapJson = stripHtmlEmbedNodes(tiptapJson);
|
||||||
// Reflect the stripped content back into the shared ydoc so the node is
|
// Reflect the stripped content back into the shared ydoc so the node is
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
canAuthorHtmlEmbed,
|
canAuthorHtmlEmbed,
|
||||||
hasHtmlEmbedNode,
|
hasHtmlEmbedNode,
|
||||||
|
htmlEmbedAllowed,
|
||||||
|
isHtmlEmbedFeatureEnabled,
|
||||||
stripHtmlEmbedNodes,
|
stripHtmlEmbedNodes,
|
||||||
} from './html-embed.util';
|
} from './html-embed.util';
|
||||||
import { htmlToJson, jsonToHtml } from '../../../collaboration/collaboration.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
|
// 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
|
// 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
|
// deleted/misplaced real guard would have kept those green. The stand-in is
|
||||||
|
|||||||
@@ -66,3 +66,37 @@ export function hasHtmlEmbedNode(pmJson: unknown): boolean {
|
|||||||
export function canAuthorHtmlEmbed(role: string | null | undefined): boolean {
|
export function canAuthorHtmlEmbed(role: string | null | undefined): boolean {
|
||||||
return role === 'owner' || role === 'admin';
|
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<string, unknown>).htmlEmbed === true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import {
|
import {
|
||||||
canAuthorHtmlEmbed,
|
|
||||||
hasHtmlEmbedNode,
|
hasHtmlEmbedNode,
|
||||||
|
htmlEmbedAllowed,
|
||||||
stripHtmlEmbedNodes,
|
stripHtmlEmbedNodes,
|
||||||
} from '../../../common/helpers/prosemirror/html-embed.util';
|
} 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).
|
// The real predicate both paths apply (see SECURITY blocks in page.service.ts):
|
||||||
function applyGate(json: any, role: string | null | undefined) {
|
// toggle AND admin.
|
||||||
if (!canAuthorHtmlEmbed(role) && hasHtmlEmbedNode(json)) {
|
function applyGate(
|
||||||
|
json: any,
|
||||||
|
featureEnabled: boolean,
|
||||||
|
role: string | null | undefined,
|
||||||
|
) {
|
||||||
|
if (!htmlEmbedAllowed(featureEnabled, role) && hasHtmlEmbedNode(json)) {
|
||||||
return stripHtmlEmbedNodes(json);
|
return stripHtmlEmbedNodes(json);
|
||||||
}
|
}
|
||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('page create/duplicate gate decision (real helpers)', () => {
|
describe('page create/duplicate gate decision (real helpers)', () => {
|
||||||
it('non-admin (member) strips', () => {
|
it('toggle ON + non-admin (member) strips', () => {
|
||||||
const result = applyGate(docWithEmbed(), 'member');
|
const result = applyGate(docWithEmbed(), true, 'member');
|
||||||
expect(hasHtmlEmbedNode(result)).toBe(false);
|
expect(hasHtmlEmbedNode(result)).toBe(false);
|
||||||
expect(result.content).toHaveLength(1);
|
expect(result.content).toHaveLength(1);
|
||||||
expect(result.content[0].content[0].text).toBe('body');
|
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) {
|
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', () => {
|
it('toggle ON + admin/owner keep', () => {
|
||||||
expect(hasHtmlEmbedNode(applyGate(docWithEmbed(), 'admin'))).toBe(true);
|
expect(hasHtmlEmbedNode(applyGate(docWithEmbed(), true, 'admin'))).toBe(
|
||||||
expect(hasHtmlEmbedNode(applyGate(docWithEmbed(), 'owner'))).toBe(true);
|
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');
|
const SRC = readFileSync(join(__dirname, 'page.service.ts'), 'utf-8');
|
||||||
|
|
||||||
describe('page create/duplicate gate identity is pinned (source contract)', () => {
|
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
|
// 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(
|
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)');
|
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(
|
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(');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,10 +31,12 @@ import {
|
|||||||
removeMarkTypeFromDoc,
|
removeMarkTypeFromDoc,
|
||||||
} from '../../../common/helpers/prosemirror/utils';
|
} from '../../../common/helpers/prosemirror/utils';
|
||||||
import {
|
import {
|
||||||
canAuthorHtmlEmbed,
|
|
||||||
hasHtmlEmbedNode,
|
hasHtmlEmbedNode,
|
||||||
|
htmlEmbedAllowed,
|
||||||
|
isHtmlEmbedFeatureEnabled,
|
||||||
stripHtmlEmbedNodes,
|
stripHtmlEmbedNodes,
|
||||||
} from '../../../common/helpers/prosemirror/html-embed.util';
|
} from '../../../common/helpers/prosemirror/html-embed.util';
|
||||||
|
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||||
import {
|
import {
|
||||||
htmlToJson,
|
htmlToJson,
|
||||||
jsonToNode,
|
jsonToNode,
|
||||||
@@ -79,6 +81,7 @@ 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(
|
||||||
@@ -146,9 +149,18 @@ export class PageService {
|
|||||||
// <!--html-embed:BASE64--> forms that parse to the same node) containing an
|
// <!--html-embed:BASE64--> forms that parse to the same node) containing an
|
||||||
// htmlEmbed and store XSS for every reader. Strip every htmlEmbed node when
|
// htmlEmbed and store XSS for every reader. Strip every htmlEmbed node when
|
||||||
// the caller is not an admin, BEFORE deriving textContent/ydoc/insert.
|
// 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(
|
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);
|
prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson);
|
||||||
}
|
}
|
||||||
@@ -614,6 +626,12 @@ 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);
|
||||||
@@ -724,11 +742,11 @@ export class PageService {
|
|||||||
// htmlEmbed node from each duplicated page when the duplicating user is
|
// htmlEmbed node from each duplicated page when the duplicating user is
|
||||||
// not an admin, BEFORE computing textContent/ydoc/insert.
|
// not an admin, BEFORE computing textContent/ydoc/insert.
|
||||||
if (
|
if (
|
||||||
!canAuthorHtmlEmbed(authUser.role) &&
|
!htmlEmbedAllowed(htmlEmbedEnabled, authUser.role) &&
|
||||||
hasHtmlEmbedNode(prosemirrorJson)
|
hasHtmlEmbedNode(prosemirrorJson)
|
||||||
) {
|
) {
|
||||||
this.logger.warn(
|
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);
|
prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const sourceContentWithEmbed = () => ({
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
function buildService() {
|
function buildService(featureEnabled = true) {
|
||||||
const pageRepo = {
|
const pageRepo = {
|
||||||
findById: jest.fn(async (id: string) => ({
|
findById: jest.fn(async (id: string) => ({
|
||||||
id,
|
id,
|
||||||
@@ -44,6 +44,13 @@ function buildService() {
|
|||||||
validateCanEdit: jest.fn(async () => undefined),
|
validateCanEdit: jest.fn(async () => undefined),
|
||||||
validateCanView: 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(
|
const service = new TransclusionService(
|
||||||
{} as any, // db (unused on this path)
|
{} as any, // db (unused on this path)
|
||||||
@@ -55,6 +62,7 @@ function buildService() {
|
|||||||
attachmentRepo as any,
|
attachmentRepo as any,
|
||||||
storageService as any,
|
storageService as any,
|
||||||
pageAccessService as any,
|
pageAccessService as any,
|
||||||
|
workspaceRepo as any,
|
||||||
);
|
);
|
||||||
return service;
|
return service;
|
||||||
}
|
}
|
||||||
@@ -90,8 +98,8 @@ describe('TransclusionService.unsyncReference htmlEmbed admin gate (real code)',
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('admin: returned content keeps the htmlEmbed', async () => {
|
it('toggle ON + admin: returned content keeps the htmlEmbed', async () => {
|
||||||
const service = buildService();
|
const service = buildService(true);
|
||||||
const { content } = await service.unsyncReference(
|
const { content } = await service.unsyncReference(
|
||||||
REF_PAGE,
|
REF_PAGE,
|
||||||
SRC_PAGE,
|
SRC_PAGE,
|
||||||
@@ -101,8 +109,8 @@ describe('TransclusionService.unsyncReference htmlEmbed admin gate (real code)',
|
|||||||
expect(hasHtmlEmbedNode(content)).toBe(true);
|
expect(hasHtmlEmbedNode(content)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('owner: returned content keeps the htmlEmbed', async () => {
|
it('toggle ON + owner: returned content keeps the htmlEmbed', async () => {
|
||||||
const service = buildService();
|
const service = buildService(true);
|
||||||
const { content } = await service.unsyncReference(
|
const { content } = await service.unsyncReference(
|
||||||
REF_PAGE,
|
REF_PAGE,
|
||||||
SRC_PAGE,
|
SRC_PAGE,
|
||||||
@@ -111,4 +119,26 @@ describe('TransclusionService.unsyncReference htmlEmbed admin gate (real code)',
|
|||||||
);
|
);
|
||||||
expect(hasHtmlEmbedNode(content)).toBe(true);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ import { TransclusionLookup } from './transclusion.types';
|
|||||||
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 {
|
import {
|
||||||
canAuthorHtmlEmbed,
|
|
||||||
hasHtmlEmbedNode,
|
hasHtmlEmbedNode,
|
||||||
|
htmlEmbedAllowed,
|
||||||
|
isHtmlEmbedFeatureEnabled,
|
||||||
stripHtmlEmbedNodes,
|
stripHtmlEmbedNodes,
|
||||||
} from '../../../common/helpers/prosemirror/html-embed.util';
|
} from '../../../common/helpers/prosemirror/html-embed.util';
|
||||||
|
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||||
|
|
||||||
type ReferencingPageInfo = {
|
type ReferencingPageInfo = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -52,6 +54,7 @@ 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(
|
||||||
@@ -528,9 +531,12 @@ export class TransclusionService {
|
|||||||
// non-admin can never receive an embed payload to re-persist (the collab
|
// 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
|
// strip on the subsequent save is debounced/race-prone and must not be the
|
||||||
// only guard). Admin behavior is unchanged.
|
// 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(
|
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);
|
content = stripHtmlEmbedNodes(content);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
aiDictation: boolean;
|
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()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
|
|||||||
@@ -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.restrictApiToAdmins;
|
||||||
delete updateWorkspaceDto.aiSearch;
|
delete updateWorkspaceDto.aiSearch;
|
||||||
delete updateWorkspaceDto.generativeAi;
|
delete updateWorkspaceDto.generativeAi;
|
||||||
@@ -519,6 +533,7 @@ export class WorkspaceService {
|
|||||||
delete updateWorkspaceDto.allowMemberTemplates;
|
delete updateWorkspaceDto.allowMemberTemplates;
|
||||||
delete updateWorkspaceDto.aiChat;
|
delete updateWorkspaceDto.aiChat;
|
||||||
delete updateWorkspaceDto.aiDictation;
|
delete updateWorkspaceDto.aiDictation;
|
||||||
|
delete updateWorkspaceDto.htmlEmbed;
|
||||||
|
|
||||||
await this.workspaceRepo.updateWorkspace(
|
await this.workspaceRepo.updateWorkspace(
|
||||||
updateWorkspaceDto,
|
updateWorkspaceDto,
|
||||||
|
|||||||
@@ -265,6 +265,32 @@ export class WorkspaceRepo {
|
|||||||
.executeTakeFirst();
|
.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(
|
async updateSharingSettings(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
prefKey: string,
|
prefKey: string,
|
||||||
|
|||||||
@@ -21,11 +21,13 @@ 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 {
|
import {
|
||||||
canAuthorHtmlEmbed,
|
|
||||||
hasHtmlEmbedNode,
|
hasHtmlEmbedNode,
|
||||||
|
htmlEmbedAllowed,
|
||||||
|
isHtmlEmbedFeatureEnabled,
|
||||||
stripHtmlEmbedNodes,
|
stripHtmlEmbedNodes,
|
||||||
} from '../../../common/helpers/prosemirror/html-embed.util';
|
} from '../../../common/helpers/prosemirror/html-embed.util';
|
||||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
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,
|
||||||
@@ -60,6 +62,7 @@ export class FileImportTaskService {
|
|||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
private readonly importAttachmentService: ImportAttachmentService,
|
private readonly importAttachmentService: ImportAttachmentService,
|
||||||
private readonly userRepo: UserRepo,
|
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,
|
||||||
) {}
|
) {}
|
||||||
@@ -168,7 +171,16 @@ export class FileImportTaskService {
|
|||||||
fileTask.creatorId,
|
fileTask.creatorId,
|
||||||
fileTask.workspaceId,
|
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<string, ImportPageNode>();
|
const pagesMap = new Map<string, ImportPageNode>();
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import {
|
import {
|
||||||
canAuthorHtmlEmbed,
|
|
||||||
hasHtmlEmbedNode,
|
hasHtmlEmbedNode,
|
||||||
|
htmlEmbedAllowed,
|
||||||
stripHtmlEmbedNodes,
|
stripHtmlEmbedNodes,
|
||||||
} from '../../../common/helpers/prosemirror/html-embed.util';
|
} 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
|
// 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
|
// import.service.ts and file-import-task.service.ts): resolve the importer via
|
||||||
// userRepo.findById, then `!canAuthorHtmlEmbed(role) && hasHtmlEmbedNode(json)`.
|
// userRepo.findById, then `!canAuthorHtmlEmbed(role) && hasHtmlEmbedNode(json)`.
|
||||||
function applyImportGate(json: any, importingUser: { role?: string } | undefined) {
|
function applyImportGate(
|
||||||
if (!canAuthorHtmlEmbed(importingUser?.role) && hasHtmlEmbedNode(json)) {
|
json: any,
|
||||||
|
featureEnabled: boolean,
|
||||||
|
importingUser: { role?: string } | undefined,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!htmlEmbedAllowed(featureEnabled, importingUser?.role) &&
|
||||||
|
hasHtmlEmbedNode(json)
|
||||||
|
) {
|
||||||
return stripHtmlEmbedNodes(json);
|
return stripHtmlEmbedNodes(json);
|
||||||
}
|
}
|
||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('import gate fail-closed by resolved-user role (real helpers)', () => {
|
describe('import gate fail-closed by toggle AND resolved-user role (real helpers)', () => {
|
||||||
it('missing user (userRepo.findById -> undefined) strips the embed', () => {
|
it('toggle ON + missing user (userRepo.findById -> undefined) strips the embed', () => {
|
||||||
// findById returns undefined when the user/workspace pair does not resolve;
|
// findById returns undefined when the user/workspace pair does not resolve;
|
||||||
// undefined?.role is undefined -> canAuthorHtmlEmbed(undefined) === false.
|
// undefined?.role is undefined -> htmlEmbedAllowed(true, undefined) === false.
|
||||||
const importingUser = undefined;
|
const result = applyImportGate(docWithEmbed(), true, undefined);
|
||||||
const result = applyImportGate(docWithEmbed(), importingUser);
|
|
||||||
expect(hasHtmlEmbedNode(result)).toBe(false);
|
expect(hasHtmlEmbedNode(result)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolved role 'member' strips", () => {
|
it("toggle ON + resolved role 'member' strips", () => {
|
||||||
expect(
|
expect(
|
||||||
hasHtmlEmbedNode(applyImportGate(docWithEmbed(), { role: 'member' })),
|
hasHtmlEmbedNode(applyImportGate(docWithEmbed(), true, { role: 'member' })),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolved role 'admin' keeps the embed", () => {
|
it("toggle ON + resolved role 'admin' keeps the embed", () => {
|
||||||
expect(
|
expect(
|
||||||
hasHtmlEmbedNode(applyImportGate(docWithEmbed(), { role: 'admin' })),
|
hasHtmlEmbedNode(applyImportGate(docWithEmbed(), true, { role: 'admin' })),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolved role 'owner' keeps the embed", () => {
|
it("toggle ON + resolved role 'owner' keeps the embed", () => {
|
||||||
expect(
|
expect(
|
||||||
hasHtmlEmbedNode(applyImportGate(docWithEmbed(), { role: 'owner' })),
|
hasHtmlEmbedNode(applyImportGate(docWithEmbed(), true, { role: 'owner' })),
|
||||||
).toBe(true);
|
).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
|
// 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(
|
expect(src).toMatch(
|
||||||
/this\.userRepo\.findById\(\s*userId\s*,\s*workspaceId\s*\)/,
|
/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.
|
// And the gate uses the real strip helper.
|
||||||
expect(src).toContain('stripHtmlEmbedNodes(prosemirrorJson)');
|
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*\)/,
|
/this\.userRepo\.findById\(\s*fileTask\.creatorId\s*,\s*fileTask\.workspaceId\s*,?\s*\)/,
|
||||||
);
|
);
|
||||||
expect(src).toMatch(
|
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)');
|
expect(src).toContain('stripHtmlEmbedNodes(prosemirrorJson)');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ 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 { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||||
import {
|
import {
|
||||||
canAuthorHtmlEmbed,
|
|
||||||
hasHtmlEmbedNode,
|
hasHtmlEmbedNode,
|
||||||
|
htmlEmbedAllowed,
|
||||||
|
isHtmlEmbedFeatureEnabled,
|
||||||
stripHtmlEmbedNodes,
|
stripHtmlEmbedNodes,
|
||||||
} from '../../../common/helpers/prosemirror/html-embed.util';
|
} 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 {
|
||||||
@@ -48,6 +50,7 @@ export class ImportService {
|
|||||||
@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(
|
||||||
@@ -101,9 +104,15 @@ export class ImportService {
|
|||||||
// imports performed by a non-admin user.
|
// imports performed by a non-admin user.
|
||||||
if (prosemirrorJson && hasHtmlEmbedNode(prosemirrorJson)) {
|
if (prosemirrorJson && hasHtmlEmbedNode(prosemirrorJson)) {
|
||||||
const importingUser = await this.userRepo.findById(userId, workspaceId);
|
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(
|
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);
|
prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson);
|
||||||
}
|
}
|
||||||
|
|||||||
75
docs/html-embed-admin.md
Normal file
75
docs/html-embed-admin.md
Normal file
@@ -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.
|
||||||
Reference in New Issue
Block a user