feat(editor): admin-only raw HTML/CSS/JS embed node

Adds an htmlEmbed block node that renders and executes raw HTML/CSS/JS in the
wiki origin (e.g. an analytics tracker) — the owner-chosen variant C. Because
this is stored-XSS by design, only workspace admins/owners may get such a node
persisted; everyone executes it when reading.

- Node (editor-ext): htmlEmbed atom/isolating block; source stored base64 in
  data-source for lossless HTML<->JSON round-trip. renderHTML emits only the
  encoded marker (never inlines raw markup), so generateHTML/export/search are
  not themselves injection vectors. Registered in BOTH client extensions and
  server tiptapExtensions. Markdown round-trip via an <!--html-embed:b64-->
  comment (turndown) + a marked rule.
- Client NodeView: injects source and re-creates <script> elements so they
  actually run; edit modal; renders in read-only/share too. Slash item is
  admin-gated (adminOnly filtered by the user's workspace role).
- SERVER ENFORCEMENT (the real control — UI gating alone is insufficient):
  stripHtmlEmbedNodes() removes htmlEmbed from any document persisted by a
  non-admin, applied at every write path that introduces content from an
  untrusted author: collab onStoreDocument, REST/MCP/AI updatePageContent,
  single-file import, zip/multi-file import, page duplication, and transclusion
  unsync. Page restore introduces no new content. Public share/readonly viewers
  render fetched (already-stripped) content and do NOT open a collab socket, so
  the only residual is a transient broadcast window to concurrent authenticated
  editors (documented).

Implements docs/arbitrary-html-embed-plan.md (variant C).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-20 08:54:54 +03:00
parent c8af637654
commit bd28dbfe2b
19 changed files with 941 additions and 9 deletions
@@ -30,6 +30,11 @@ import {
isAttachmentNode,
removeMarkTypeFromDoc,
} from '../../../common/helpers/prosemirror/utils';
import {
canAuthorHtmlEmbed,
hasHtmlEmbedNode,
stripHtmlEmbedNodes,
} from '../../../common/helpers/prosemirror/html-embed.util';
import {
htmlToJson,
jsonToNode,
@@ -688,7 +693,25 @@ export class PageService {
}
});
const prosemirrorJson = prosemirrorDoc.toJSON();
let prosemirrorJson = prosemirrorDoc.toJSON();
// SECURITY (Variant C admin gate, duplication write path):
// Duplication builds the ydoc directly and bypasses the collab
// onStoreDocument strip. htmlEmbed renders raw, unsanitized JS in
// readers' browsers, so only workspace admins/owners may author it. A
// non-admin with space Edit could otherwise duplicate an admin page
// that contains an embed into a new page authored by them. Strip every
// htmlEmbed node from each duplicated page when the duplicating user is
// not an admin, BEFORE computing textContent/ydoc/insert.
if (
!canAuthorHtmlEmbed(authUser.role) &&
hasHtmlEmbedNode(prosemirrorJson)
) {
this.logger.warn(
`Stripping htmlEmbed node(s) from non-admin page duplication by user ${authUser.id} (source page ${page.id})`,
);
prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson);
}
// Add "Copy of " prefix to the root page title only for duplicates in same space
let title = page.title;
@@ -23,6 +23,11 @@ import { rewriteAttachmentsForUnsync } from './utils/transclusion-unsync.util';
import { TransclusionLookup } from './transclusion.types';
import { Page, User } from '@docmost/db/types/entity.types';
import { PageAccessService } from '../page-access/page-access.service';
import {
canAuthorHtmlEmbed,
hasHtmlEmbedNode,
stripHtmlEmbedNodes,
} from '../../../common/helpers/prosemirror/html-embed.util';
type ReferencingPageInfo = {
id: string;
@@ -461,10 +466,12 @@ export class TransclusionService {
throw new NotFoundException('Sync block not found');
}
const { content, copies } = rewriteAttachmentsForUnsync(
let content: unknown;
let copies: ReturnType<typeof rewriteAttachmentsForUnsync>['copies'];
({ content, copies } = rewriteAttachmentsForUnsync(
transclusion.content,
() => uuid7(),
);
));
if (copies.length > 0) {
const oldIds = copies.map((c) => c.oldAttachmentId);
@@ -513,6 +520,21 @@ export class TransclusionService {
transclusionId,
);
// SECURITY (Variant C admin gate, transclusion unsync write path):
// The returned content is a source snapshot that the client materializes
// into the reference page via insertContentAt. The snapshot keeps any
// htmlEmbed verbatim, and unsync requires only space Edit/View. If the
// requesting user is not a workspace admin/owner, strip htmlEmbed nodes so a
// non-admin can never receive an embed payload to re-persist (the collab
// strip on the subsequent save is debounced/race-prone and must not be the
// only guard). Admin behavior is unchanged.
if (!canAuthorHtmlEmbed(user.role) && hasHtmlEmbedNode(content)) {
this.logger.warn(
`Stripping htmlEmbed node(s) from non-admin transclusion unsync by user ${user.id} (reference page ${referencePageId}, source page ${sourcePageId})`,
);
content = stripHtmlEmbedNodes(content);
}
return { content };
}
}