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:
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
canAuthorHtmlEmbed,
|
||||
hasHtmlEmbedNode,
|
||||
htmlEmbedAllowed,
|
||||
isHtmlEmbedFeatureEnabled,
|
||||
stripHtmlEmbedNodes,
|
||||
} from './html-embed.util';
|
||||
import { htmlToJson, jsonToHtml } from '../../../collaboration/collaboration.util';
|
||||
@@ -105,6 +107,40 @@ describe('canAuthorHtmlEmbed', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isHtmlEmbedFeatureEnabled', () => {
|
||||
it('is true only when settings.htmlEmbed === true', () => {
|
||||
expect(isHtmlEmbedFeatureEnabled({ htmlEmbed: true })).toBe(true);
|
||||
});
|
||||
it('defaults to false (absent / false / non-object)', () => {
|
||||
expect(isHtmlEmbedFeatureEnabled({})).toBe(false);
|
||||
expect(isHtmlEmbedFeatureEnabled({ htmlEmbed: false })).toBe(false);
|
||||
expect(isHtmlEmbedFeatureEnabled(null)).toBe(false);
|
||||
expect(isHtmlEmbedFeatureEnabled(undefined)).toBe(false);
|
||||
// Truthy-but-not-true values must NOT enable the feature.
|
||||
expect(isHtmlEmbedFeatureEnabled({ htmlEmbed: 'true' as any })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('htmlEmbedAllowed (toggle AND admin)', () => {
|
||||
it('toggle OFF + admin/owner => not allowed (feature disabled for everyone)', () => {
|
||||
expect(htmlEmbedAllowed(false, 'admin')).toBe(false);
|
||||
expect(htmlEmbedAllowed(false, 'owner')).toBe(false);
|
||||
});
|
||||
it('toggle OFF + member => not allowed', () => {
|
||||
expect(htmlEmbedAllowed(false, 'member')).toBe(false);
|
||||
});
|
||||
it('toggle ON + admin/owner => allowed', () => {
|
||||
expect(htmlEmbedAllowed(true, 'admin')).toBe(true);
|
||||
expect(htmlEmbedAllowed(true, 'owner')).toBe(true);
|
||||
});
|
||||
it('toggle ON + member/unknown => not allowed', () => {
|
||||
expect(htmlEmbedAllowed(true, 'member')).toBe(false);
|
||||
expect(htmlEmbedAllowed(true, null)).toBe(false);
|
||||
expect(htmlEmbedAllowed(true, undefined)).toBe(false);
|
||||
expect(htmlEmbedAllowed(true, 'viewer')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// NOTE: a previous revision of this file re-implemented the write-path admin
|
||||
// gate as a local `applyAdminGate` stand-in and asserted against THAT. A
|
||||
// deleted/misplaced real guard would have kept those green. The stand-in is
|
||||
|
||||
@@ -66,3 +66,37 @@ export function hasHtmlEmbedNode(pmJson: unknown): boolean {
|
||||
export function canAuthorHtmlEmbed(role: string | null | undefined): boolean {
|
||||
return role === 'owner' || role === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined write-path gate for the htmlEmbed feature.
|
||||
*
|
||||
* htmlEmbed is allowed in a document only when the workspace feature toggle is
|
||||
* ON and the authoring/saving user is a workspace admin/owner. OFF (default) =>
|
||||
* stripped for EVERYONE, including admins (the feature is disabled).
|
||||
*
|
||||
* `featureEnabled` is read from the workspace settings for the relevant write
|
||||
* (`workspace.settings?.htmlEmbed === true`). Every WRITE path that may persist
|
||||
* htmlEmbed content must gate on this combined predicate, so that turning the
|
||||
* toggle OFF strips existing embeds on the next save and prevents new ones from
|
||||
* being persisted regardless of role.
|
||||
*/
|
||||
export function htmlEmbedAllowed(
|
||||
featureEnabled: boolean,
|
||||
role: string | null | undefined,
|
||||
): boolean {
|
||||
return featureEnabled === true && canAuthorHtmlEmbed(role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the workspace-level htmlEmbed feature toggle from a workspace's settings
|
||||
* jsonb. ABSENT/non-true => OFF (the default). Kept here so every server write
|
||||
* path resolves the toggle the same way.
|
||||
*/
|
||||
export function isHtmlEmbedFeatureEnabled(
|
||||
settings: unknown | null | undefined,
|
||||
): boolean {
|
||||
if (!settings || typeof settings !== 'object') {
|
||||
return false;
|
||||
}
|
||||
return (settings as Record<string, unknown>).htmlEmbed === true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user