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:
@@ -20,6 +20,12 @@ import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||
import { FileTask, InsertablePage } from '@docmost/db/types/entity.types';
|
||||
import { markdownToHtml } from '@docmost/editor-ext';
|
||||
import { getProsemirrorContent } from '../../../common/helpers/prosemirror/utils';
|
||||
import {
|
||||
canAuthorHtmlEmbed,
|
||||
hasHtmlEmbedNode,
|
||||
stripHtmlEmbedNodes,
|
||||
} from '../../../common/helpers/prosemirror/html-embed.util';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { formatImportHtml } from '../utils/import-formatter';
|
||||
import {
|
||||
buildAttachmentCandidates,
|
||||
@@ -53,6 +59,7 @@ export class FileImportTaskService {
|
||||
private readonly backlinkRepo: BacklinkRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly importAttachmentService: ImportAttachmentService,
|
||||
private readonly userRepo: UserRepo,
|
||||
private eventEmitter: EventEmitter2,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
@@ -149,6 +156,20 @@ export class FileImportTaskService {
|
||||
.where('id', '=', fileTask.spaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
// SECURITY (Variant C admin gate, zip/multi-file import write path):
|
||||
// An imported .html/.md file can carry an htmlEmbed marker (the node's
|
||||
// serialized form), which would execute raw, unsanitized JS in readers'
|
||||
// browsers. Only workspace admins/owners may author it. Resolve the
|
||||
// importer's role ONCE here; each page's prosemirror JSON is run through the
|
||||
// strip below before textContent/ydoc/insert when the importer is not an
|
||||
// admin, so a non-admin cannot smuggle the node in via a zip import (which
|
||||
// requires only space Edit).
|
||||
const importingUser = await this.userRepo.findById(
|
||||
fileTask.creatorId,
|
||||
fileTask.workspaceId,
|
||||
);
|
||||
const importerCanAuthorHtmlEmbed = canAuthorHtmlEmbed(importingUser?.role);
|
||||
|
||||
const pagesMap = new Map<string, ImportPageNode>();
|
||||
|
||||
for (const absPath of allFiles) {
|
||||
@@ -496,9 +517,21 @@ export class FileImportTaskService {
|
||||
await this.importService.processHTML(html),
|
||||
);
|
||||
|
||||
const { title, prosemirrorJson } =
|
||||
let { title, prosemirrorJson } =
|
||||
this.importService.extractTitleAndRemoveHeading(pmState);
|
||||
|
||||
// SECURITY (Variant C admin gate): strip htmlEmbed nodes from pages
|
||||
// imported by a non-admin BEFORE computing textContent/ydoc/insert.
|
||||
if (
|
||||
!importerCanAuthorHtmlEmbed &&
|
||||
hasHtmlEmbedNode(prosemirrorJson)
|
||||
) {
|
||||
this.logger.warn(
|
||||
`Stripping htmlEmbed node(s) from non-admin import by user ${fileTask.creatorId} (page ${page.id}, file ${filePath})`,
|
||||
);
|
||||
prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson);
|
||||
}
|
||||
|
||||
const insertablePage: InsertablePage = {
|
||||
id: page.id,
|
||||
slugId: page.slugId,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import {
|
||||
canAuthorHtmlEmbed,
|
||||
hasHtmlEmbedNode,
|
||||
stripHtmlEmbedNodes,
|
||||
} from '../../../common/helpers/prosemirror/html-embed.util';
|
||||
import { MultipartFile } from '@fastify/multipart';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
@@ -37,6 +43,7 @@ export class ImportService {
|
||||
|
||||
constructor(
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly userRepo: UserRepo,
|
||||
private readonly storageService: StorageService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@InjectQueue(QueueName.FILE_TASK_QUEUE)
|
||||
@@ -83,8 +90,24 @@ export class ImportService {
|
||||
throw new BadRequestException(message);
|
||||
}
|
||||
|
||||
const { title, prosemirrorJson } =
|
||||
this.extractTitleAndRemoveHeading(prosemirrorState);
|
||||
const extracted = this.extractTitleAndRemoveHeading(prosemirrorState);
|
||||
const title = extracted.title;
|
||||
let prosemirrorJson = extracted.prosemirrorJson;
|
||||
|
||||
// SECURITY (Variant C admin gate, import write path):
|
||||
// An imported .html/.md file can carry an htmlEmbed marker (the node's
|
||||
// serialized form), which would execute raw JS in readers' browsers. Only
|
||||
// workspace admins/owners may author it, so strip htmlEmbed nodes from
|
||||
// imports performed by a non-admin user.
|
||||
if (prosemirrorJson && hasHtmlEmbedNode(prosemirrorJson)) {
|
||||
const importingUser = await this.userRepo.findById(userId, workspaceId);
|
||||
if (!canAuthorHtmlEmbed(importingUser?.role)) {
|
||||
this.logger.warn(
|
||||
`Stripping htmlEmbed node(s) from non-admin import by user ${userId}`,
|
||||
);
|
||||
prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson);
|
||||
}
|
||||
}
|
||||
|
||||
const pageTitle = title || fileName;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user