Convert the htmlEmbed node from same-origin raw-HTML execution to a sandboxed iframe (sandbox="allow-scripts allow-popups allow-forms", no allow-same-origin, srcdoc) with postMessage auto-resize (validated by event.source) and an optional manual height attr. The block now runs in an opaque origin and cannot reach the viewer's cookies/session/API, so it is safe for any member. Because the block is now harmless, remove the entire admin/role gating apparatus: drop htmlEmbedAllowed/canAuthorHtmlEmbed/stripDisallowedHtmlEmbedNodes/ collectHtmlEmbedSources and every role-based strip on the write paths (collab REST/MCP + socket, page create/duplicate, import x2, transclusion unsync), along with the now-unused WorkspaceRepo/UserRepo injections and the PageService.create callerRole param. Keep one strip: prepareContentForShare still removes htmlEmbed on the anonymous public-share read path when the workspace master toggle is OFF. The workspace settings.htmlEmbed toggle is now a plain feature switch (gates the slash-menu and share rendering); when ON the block is available to all members. Add settings.trackerHead: an admin-only raw HTML/JS analytics snippet injected verbatim into the <head> of public share pages only (ShareSeoController), for trackers that genuinely need same-origin. Admin-gated via the existing CASL Manage/Settings ability; never injected into the authenticated app shell. Closes security-review findings #1, #2, #4, #5, #10 (and #3 as a security issue). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
74 lines
2.5 KiB
TypeScript
74 lines
2.5 KiB
TypeScript
import { JSONContent } from '@tiptap/core';
|
|
|
|
export const HTML_EMBED_NODE_NAME = 'htmlEmbed';
|
|
|
|
/**
|
|
* Recursively remove every `htmlEmbed` node from a ProseMirror JSON document.
|
|
*
|
|
* The `htmlEmbed` node renders inside a SANDBOXED iframe (no `allow-same-origin`)
|
|
* on the client, so its content cannot touch the viewer's session/cookies/API —
|
|
* it is NOT a stored-XSS surface. This helper is retained ONLY to honor the
|
|
* workspace master toggle (`settings.htmlEmbed`) on the anonymous public-share
|
|
* read path: an anonymous viewer cannot read the workspace toggle, so the server
|
|
* strips the block when the toggle is OFF before serving shared content.
|
|
*
|
|
* Returns a NEW document; the input is not mutated. If the input is not a valid
|
|
* doc object it is returned unchanged (callers persist what they were given).
|
|
*/
|
|
export function stripHtmlEmbedNodes<T = JSONContent>(pmJson: T): T {
|
|
if (!pmJson || typeof pmJson !== 'object') {
|
|
return pmJson;
|
|
}
|
|
|
|
const node = pmJson as unknown as JSONContent;
|
|
|
|
if (Array.isArray(node.content)) {
|
|
const filtered: JSONContent[] = [];
|
|
for (const child of node.content) {
|
|
// Drop any htmlEmbed child outright.
|
|
if (child && child.type === HTML_EMBED_NODE_NAME) {
|
|
continue;
|
|
}
|
|
// Recurse so nested htmlEmbed nodes (e.g. inside columns/callouts) are
|
|
// also removed.
|
|
filtered.push(stripHtmlEmbedNodes(child));
|
|
}
|
|
return { ...node, content: filtered } as unknown as T;
|
|
}
|
|
|
|
return { ...node } as unknown as T;
|
|
}
|
|
|
|
/**
|
|
* Returns true if the document contains at least one `htmlEmbed` node anywhere
|
|
* in its tree. Useful to decide whether a strip pass on the share read path
|
|
* actually changed anything.
|
|
*/
|
|
export function hasHtmlEmbedNode(pmJson: unknown): boolean {
|
|
if (!pmJson || typeof pmJson !== 'object') {
|
|
return false;
|
|
}
|
|
const node = pmJson as JSONContent;
|
|
if (node.type === HTML_EMBED_NODE_NAME) {
|
|
return true;
|
|
}
|
|
if (Array.isArray(node.content)) {
|
|
return node.content.some((child) => hasHtmlEmbedNode(child));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Read the workspace-level htmlEmbed master toggle from a workspace's settings
|
|
* jsonb. ABSENT/non-true => OFF (the default). Kept here so the share read path
|
|
* resolves the toggle the same way it is persisted.
|
|
*/
|
|
export function isHtmlEmbedFeatureEnabled(
|
|
settings: unknown | null | undefined,
|
|
): boolean {
|
|
if (!settings || typeof settings !== 'object') {
|
|
return false;
|
|
}
|
|
return (settings as Record<string, unknown>).htmlEmbed === true;
|
|
}
|