feat(html-embed): sandbox the embed block; split trusted trackers into an admin field

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>
This commit is contained in:
claude_code
2026-06-21 02:48:41 +03:00
parent b98c9d51c6
commit 81823fce1e
35 changed files with 482 additions and 1387 deletions

View File

@@ -8,13 +8,6 @@ import {
import { setYjsMark, updateYjsMarkAttribute, YjsSelection } from './yjs.util';
import * as Y from 'yjs';
import { User } from '@docmost/db/types/entity.types';
import {
hasHtmlEmbedNode,
htmlEmbedAllowed,
isHtmlEmbedFeatureEnabled,
stripHtmlEmbedNodes,
} from '../common/helpers/prosemirror/html-embed.util';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
export type CollabEventHandlers = ReturnType<
CollaborationHandler['getHandlers']
@@ -24,8 +17,6 @@ export type CollabEventHandlers = ReturnType<
export class CollaborationHandler {
private readonly logger = new Logger(CollaborationHandler.name);
constructor(private readonly workspaceRepo: WorkspaceRepo) {}
getHandlers(hocuspocus: Hocuspocus) {
return {
alterState: async (documentName: string, payload: { pageId: string }) => {
@@ -91,30 +82,9 @@ export class CollaborationHandler {
},
) => {
const { operation, user } = payload;
let { prosemirrorJson } = payload;
const { prosemirrorJson } = payload;
this.logger.debug('Updating page content via yjs', documentName);
// SECURITY (Variant C admin gate, REST/MCP/AI write path):
// updatePageContent is the server-side entrypoint used by the REST page
// update endpoint and by the MCP/AI agent. Raw `htmlEmbed` nodes execute
// arbitrary JS in every reader's browser, so a NON-admin caller must not
// be able to persist them here. If the editing user is not a workspace
// admin/owner, strip every htmlEmbed node before it reaches the ydoc.
// Toggle-AND-admin gate: htmlEmbed survives only when the workspace
// feature toggle is ON and the editing user is an admin/owner. OFF
// (default) => stripped for everyone.
const htmlEmbedEnabled = isHtmlEmbedFeatureEnabled(
(await this.workspaceRepo.findById(user?.workspaceId))?.settings,
);
if (!htmlEmbedAllowed(htmlEmbedEnabled, user?.role)) {
if (hasHtmlEmbedNode(prosemirrorJson)) {
this.logger.warn(
`Stripping htmlEmbed node(s) from update by user ${user?.id} on ${documentName}`,
);
prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson);
}
}
await this.withYdocConnection(
hocuspocus,
documentName,