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";
|
||||
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<HTMLDivElement | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<string>(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 (
|
||||
<NodeViewWrapper
|
||||
@@ -102,7 +116,16 @@ export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
</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
|
||||
// in renderRawHtml so they execute.
|
||||
<div ref={contentRef} className={classes.htmlEmbedContent} />
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user