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:
claude code agent 227
2026-06-20 19:28:39 +03:00
parent caac5c7f36
commit 8fcce6a674
23 changed files with 610 additions and 78 deletions
@@ -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>
);
}