feat(editor): admin-only raw HTML/CSS/JS embed (variant C) #16
Reference in New Issue
Block a user
Delete Branch "feat/html-embed-admin"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Implements
docs/arbitrary-html-embed-plan.md— the owner-chosen Variant C: admin-only raw injection.What
A new
htmlEmbedblock node renders and executes raw HTML/CSS/JS in the wiki origin (the original use-case: an analytics tracker that can read cookies / the page URL — which a sandboxed iframe can't). Because this is stored-XSS by design, the safety model is: only workspace admins/owners can get such a node persisted; everyone executes it when reading.How
Node (editor-ext):
htmlEmbed(atom, isolating, block).sourceis stored base64 indata-sourcefor lossless HTML↔JSON round-trip.renderHTMLemits only the encoded marker — it never inlines the raw markup, sogenerateHTML/ export / search-index are not themselves injection vectors (only the client NodeView expands+executes). Registered in both the client extensions and the servertiptapExtensions(or the server would strip it). Markdown round-trips via an<!--html-embed:base64-->comment (turndown) + a marked rule.Client NodeView: sets the source as HTML and re-creates
<script>elements so they actually run (innerHTML-injected scripts don't auto-run); edit modal with a code textarea; renders in read-only/share too. The slash-menu "HTML embed" item is admin-gated (filtered by the user's workspace role).Server enforcement (the real control): a client-only gate is insufficient — a non-admin could inject the node via the collab socket, REST/MCP/AI, paste, import, or duplication.
stripHtmlEmbedNodes()removeshtmlEmbedfrom any document persisted by a non-admin, applied at every write path that introduces content from an untrusted author.Reasoning / decisions
renderHTMLdeliberately stores an encoded marker, not live markup, so no server-side HTML generation path becomes an injection vector.Review findings & fixes (adversarial security review)
The review confirmed the guarded paths and
renderHTMLare safe, but found 2 BLOCKERS — write paths that wrote content directly, bypassing the strip, reachable by a non-admin with space-Edit:file-import-task.service) wrotecontent/ydocwith no strip → fixed: resolve the importer's role once, strip for non-admins.duplicatePage) copied content directly (explicitly bypassing the collab strip) → fixed: strip per duplicated page when the duplicating user isn't an admin.Also fixed transclusion unsync (returned a source snapshot retaining the embed → strip for non-admin callers). For the pre-persist broadcast window (WARNING): I verified anonymous public-share viewers do NOT open a collab socket (they render fetched, already-stripped content via
ReadonlyPageEditor— noHocuspocusProvider), so the only residual is a transient execution among concurrent authenticated editors (semi-trusted space members) before the debounced strip — documented as accepted.Final guard table — every non-admin write path now strips: collab persist ✓, REST/MCP/AI update ✓, single import ✓, zip import ✓ (new), duplication ✓ (new), transclusion unsync ✓ (new); restore introduces no new content. No remaining path lets a non-admin persist an executing
htmlEmbed.Verification
pnpm --filter @docmost/editor-ext build+pnpm --filter server build+pnpm --filter client build— clean.html-embed.spec.ts— 15 pass (strip incl. nested subtrees;canAuthorHtmlEmbedrole matrix; base64 UTF-8 codec; HTML↔JSON node round-trip keepssource; admin-gate write-path decision).#emb-probe/window.__embedProbe/console) executed (all three signals); after a save + full reload the node is still present and re-executes (proves it survives the collab save and isn't stripped for an admin). No real app errors. Screenshots captured.🤖 Generated with Claude Code