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>
3.7 KiB
3.7 KiB
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.htmlEmbedabsent orfalse). 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
/htmlslash 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: booleanonUpdateWorkspaceDto. - Persisted by
WorkspaceService.updateviaWorkspaceRepo.updateSetting(workspaceId, 'htmlEmbed', value)(top-level scalar settings key; analogous toupdateAiSettings). The change is audit-logged like the AI toggles.
Server gate
apps/server/src/common/helpers/prosemirror/html-embed.util.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()andduplicatePage()collaboration/extensions/persistence.extension.ts— collab storecollaboration/collaboration.handler.ts— REST/MCP/AI content updateintegrations/import/services/import.service.ts— single importintegrations/import/services/file-import-task.service.ts— zip importcore/page/transclusion/transclusion.service.ts— transclusion unsync
Client
- Slash menu: the
/htmlitem carriesrequiresHtmlEmbedFeature: trueandadminOnly: true; it is hidden unless the persistedworkspace.settings.htmlEmbed === trueAND the user is admin. The slash function reads the toggle from the persistedcurrentUserlocalStorage entry (same mechanism asisCurrentUserAdmin()). - 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 optimisticupdateWorkspace({ htmlEmbed }), with a description documenting the security implications.