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:
@@ -1,120 +0,0 @@
|
||||
import * as Y from 'yjs';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import { CollaborationHandler } from './collaboration.handler';
|
||||
import { hasHtmlEmbedNode } from '../common/helpers/prosemirror/html-embed.util';
|
||||
|
||||
// Exercises the REAL CollaborationHandler.updatePageContent admin gate (the
|
||||
// REST/MCP/AI content-update entrypoint, used by the page update endpoint and
|
||||
// the MCP/AI agent). updatePageContent reads `user?.role` and strips htmlEmbed
|
||||
// BEFORE handing the json to withYdocConnection. We stub only
|
||||
// withYdocConnection (which would otherwise open a real hocuspocus connection):
|
||||
// the role-extraction (`user?.role`) + strip that run upstream of it are REAL
|
||||
// production code. The 'replace' branch then runs the production
|
||||
// TiptapTransformer.toYdoc on the gated json against a real Y.Doc, which we
|
||||
// decode back to JSON and assert on. This replaces the re-implemented
|
||||
// `applyAdminGate` stand-in for this entrypoint.
|
||||
|
||||
const docWithEmbed = () => ({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'keep' }] },
|
||||
{
|
||||
type: 'columns',
|
||||
content: [
|
||||
{
|
||||
type: 'column',
|
||||
attrs: { position: 'left' },
|
||||
content: [
|
||||
{ type: 'htmlEmbed', attrs: { source: '<script>nested</script>' } },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'inner' }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'column',
|
||||
attrs: { position: 'right' },
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'r' }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'htmlEmbed', attrs: { source: '<script>top</script>' } },
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* Run the REAL updatePageContent('replace') with a stubbed withYdocConnection.
|
||||
* The stub provides a real Y.Doc + recording fragment; the production fn calls
|
||||
* TiptapTransformer.toYdoc(<gated json>) and applies it to the doc, so decoding
|
||||
* the doc afterward yields exactly the gated content.
|
||||
*/
|
||||
async function gatedContentFor(
|
||||
role: string | null | undefined,
|
||||
featureEnabled = true,
|
||||
) {
|
||||
// Workspace settings read used by the toggle-AND-admin gate.
|
||||
const workspaceRepo = {
|
||||
findById: jest.fn(async () => ({
|
||||
id: 'ws-1',
|
||||
settings: { htmlEmbed: featureEnabled },
|
||||
})),
|
||||
};
|
||||
const handler = new CollaborationHandler(workspaceRepo as any);
|
||||
const captureDoc = new Y.Doc();
|
||||
|
||||
jest
|
||||
.spyOn(handler, 'withYdocConnection')
|
||||
.mockImplementation(async (_hp, _name, _ctx, fn: any) => {
|
||||
const fragment = captureDoc.getXmlFragment('default');
|
||||
// Mirror the real Document surface the fn touches.
|
||||
const docLike: any = {
|
||||
getXmlFragment: () => fragment,
|
||||
};
|
||||
// The fn does: fragment.delete(0,len) then
|
||||
// Y.applyUpdate(doc, encodeStateAsUpdate(toYdoc(gatedJson))). It calls
|
||||
// Y.applyUpdate(doc, ...) — so docLike must be a real Y.Doc target.
|
||||
fn(captureDoc);
|
||||
});
|
||||
|
||||
const handlers = handler.getHandlers({} as any);
|
||||
await handlers.updatePageContent('page-1', {
|
||||
prosemirrorJson: docWithEmbed(),
|
||||
operation: 'replace',
|
||||
user: { id: 'u1', role, workspaceId: 'ws-1' } as any,
|
||||
});
|
||||
|
||||
return TiptapTransformer.fromYdoc(captureDoc, 'default');
|
||||
}
|
||||
|
||||
describe('CollaborationHandler.updatePageContent htmlEmbed admin gate (real code)', () => {
|
||||
it('non-admin (member): every htmlEmbed (top-level + nested) stripped before the ydoc', async () => {
|
||||
const gated = await gatedContentFor('member');
|
||||
expect(hasHtmlEmbedNode(gated)).toBe(false);
|
||||
// Non-embed siblings survive.
|
||||
const json = JSON.stringify(gated);
|
||||
expect(json).toContain('keep');
|
||||
expect(json).toContain('inner');
|
||||
});
|
||||
|
||||
it('unknown/empty role: fails closed (stripped)', async () => {
|
||||
for (const role of [undefined, null, 'viewer'] as const) {
|
||||
expect(hasHtmlEmbedNode(await gatedContentFor(role))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('toggle ON + admin: htmlEmbed preserved', async () => {
|
||||
expect(hasHtmlEmbedNode(await gatedContentFor('admin', true))).toBe(true);
|
||||
});
|
||||
|
||||
it('toggle ON + owner: htmlEmbed preserved', async () => {
|
||||
expect(hasHtmlEmbedNode(await gatedContentFor('owner', true))).toBe(true);
|
||||
});
|
||||
|
||||
it('toggle OFF + admin: stripped (feature disabled for everyone)', async () => {
|
||||
expect(hasHtmlEmbedNode(await gatedContentFor('admin', false))).toBe(false);
|
||||
});
|
||||
|
||||
it('toggle OFF + member: stripped', async () => {
|
||||
expect(hasHtmlEmbedNode(await gatedContentFor('member', false))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
import * as Y from 'yjs';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import { PersistenceExtension } from './persistence.extension';
|
||||
import { tiptapExtensions } from '../collaboration.util';
|
||||
import {
|
||||
hasHtmlEmbedNode,
|
||||
HTML_EMBED_NODE_NAME,
|
||||
} from '../../common/helpers/prosemirror/html-embed.util';
|
||||
|
||||
// Exercises the REAL PersistenceExtension.onStoreDocument (the primary collab
|
||||
// WebSocket write path) against a REAL ydoc, with thin repo/db/queue mocks.
|
||||
// This replaces the prior re-implemented `applyAdminGate` stand-in for this
|
||||
// entrypoint: if the role-extraction expression (`context?.user?.role`), the
|
||||
// strip call, or the ydoc-rebuild branch is deleted/changed, these tests fail.
|
||||
|
||||
const RICH_DOC = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'intro paragraph' }],
|
||||
},
|
||||
{
|
||||
type: 'columns',
|
||||
content: [
|
||||
{
|
||||
type: 'column',
|
||||
attrs: { position: 'left' },
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'left col, mentioning ' },
|
||||
{
|
||||
type: 'mention',
|
||||
attrs: {
|
||||
id: 'mention-1',
|
||||
label: 'Alice',
|
||||
entityType: 'user',
|
||||
entityId: 'user-123',
|
||||
creatorId: 'creator-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// Nested embed inside a column — must be stripped recursively.
|
||||
{
|
||||
type: HTML_EMBED_NODE_NAME,
|
||||
attrs: { source: '<script>nested()</script>' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'column',
|
||||
attrs: { position: 'right' },
|
||||
content: [
|
||||
{
|
||||
type: 'table',
|
||||
content: [
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{
|
||||
type: 'tableHeader',
|
||||
attrs: { colspan: 1, rowspan: 1 },
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'H' }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{
|
||||
type: 'tableCell',
|
||||
attrs: { colspan: 1, rowspan: 1 },
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'cell' }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
// Top-level embed — must be stripped.
|
||||
{
|
||||
type: HTML_EMBED_NODE_NAME,
|
||||
attrs: { source: '<script>top()</script>' },
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'outro paragraph' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function buildYdoc(json: any): Y.Doc {
|
||||
return TiptapTransformer.toYdoc(json, 'default', tiptapExtensions);
|
||||
}
|
||||
|
||||
// Count nodes by type across the whole tree (excludes htmlEmbed by listing it
|
||||
// separately) so we can assert every OTHER node type survived the strip.
|
||||
function nodeTypeCounts(json: any): Record<string, number> {
|
||||
const counts: Record<string, number> = {};
|
||||
const walk = (n: any) => {
|
||||
if (!n || typeof n !== 'object') return;
|
||||
if (n.type) counts[n.type] = (counts[n.type] ?? 0) + 1;
|
||||
if (Array.isArray(n.content)) n.content.forEach(walk);
|
||||
};
|
||||
walk(json);
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a real PersistenceExtension with the minimum mocks needed for
|
||||
* onStoreDocument to reach the strip + persist branch, and capture the content
|
||||
* that would be written to the page row.
|
||||
*/
|
||||
function buildExtension(featureEnabled = true) {
|
||||
const captured: { content?: any } = {};
|
||||
|
||||
const existingPage = {
|
||||
id: 'page-1',
|
||||
slugId: 'slug-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
creatorId: 'creator-1',
|
||||
contributorIds: [],
|
||||
content: { type: 'doc', content: [] }, // differs from new content -> persist runs
|
||||
createdAt: new Date(),
|
||||
lastUpdatedSource: 'user',
|
||||
};
|
||||
|
||||
const pageRepo = {
|
||||
findById: jest.fn(async () => ({ ...existingPage })),
|
||||
updatePage: jest.fn(async (values: any) => {
|
||||
captured.content = values.content;
|
||||
}),
|
||||
};
|
||||
const pageHistoryRepo = {
|
||||
findPageLastHistory: jest.fn(async () => null),
|
||||
saveHistory: jest.fn(async () => undefined),
|
||||
};
|
||||
// db.transaction().execute(cb) just runs the callback (no real DB).
|
||||
const db = {
|
||||
transaction: () => ({
|
||||
execute: (cb: any) => cb({} as any),
|
||||
}),
|
||||
};
|
||||
const noopQueue = { add: jest.fn(async () => undefined) } as any;
|
||||
const collabHistory = { addContributors: jest.fn(async () => undefined) } as any;
|
||||
const transclusionService = {
|
||||
syncPageTransclusions: jest.fn(async () => undefined),
|
||||
syncPageReferences: jest.fn(async () => undefined),
|
||||
} as any;
|
||||
|
||||
// Workspace settings read used by the toggle-AND-admin gate.
|
||||
const workspaceRepo = {
|
||||
findById: jest.fn(async () => ({
|
||||
id: 'ws-1',
|
||||
settings: { htmlEmbed: featureEnabled },
|
||||
})),
|
||||
};
|
||||
|
||||
const ext = new PersistenceExtension(
|
||||
pageRepo as any,
|
||||
pageHistoryRepo as any,
|
||||
db as any,
|
||||
noopQueue,
|
||||
noopQueue,
|
||||
noopQueue,
|
||||
collabHistory,
|
||||
transclusionService,
|
||||
workspaceRepo as any,
|
||||
);
|
||||
|
||||
return { ext, captured, pageRepo };
|
||||
}
|
||||
|
||||
async function runStore(
|
||||
role: string | null | undefined,
|
||||
doc: Y.Doc,
|
||||
featureEnabled = true,
|
||||
) {
|
||||
const { ext, captured } = buildExtension(featureEnabled);
|
||||
// hocuspocus augments the Y.Doc with broadcastStateless; a bare Y.Doc has
|
||||
// none, so stub it (the post-persist broadcast is not under test here).
|
||||
(doc as any).broadcastStateless = () => undefined;
|
||||
await ext.onStoreDocument({
|
||||
documentName: 'page-1',
|
||||
document: doc,
|
||||
context: { user: { id: 'u1', role } },
|
||||
} as any);
|
||||
return captured;
|
||||
}
|
||||
|
||||
describe('PersistenceExtension.onStoreDocument htmlEmbed admin gate (real code)', () => {
|
||||
it('non-admin store: strips EVERY htmlEmbed but preserves every other node', async () => {
|
||||
const doc = buildYdoc(RICH_DOC);
|
||||
const before = TiptapTransformer.fromYdoc(doc, 'default');
|
||||
expect(hasHtmlEmbedNode(before)).toBe(true);
|
||||
const beforeCounts = nodeTypeCounts(before);
|
||||
|
||||
const captured = await runStore('member', doc);
|
||||
|
||||
expect(captured.content).toBeDefined();
|
||||
// htmlEmbed gone from the persisted content.
|
||||
expect(hasHtmlEmbedNode(captured.content)).toBe(false);
|
||||
|
||||
// Every non-embed node type is preserved with the SAME count (guards against
|
||||
// data loss if a node were missing from tiptapExtensions and dropped on the
|
||||
// toYdoc rebuild).
|
||||
const afterCounts = nodeTypeCounts(captured.content);
|
||||
for (const [type, count] of Object.entries(beforeCounts)) {
|
||||
if (type === HTML_EMBED_NODE_NAME) continue;
|
||||
expect(afterCounts[type]).toBe(count);
|
||||
}
|
||||
// The two embeds are gone.
|
||||
expect(beforeCounts[HTML_EMBED_NODE_NAME]).toBe(2);
|
||||
expect(afterCounts[HTML_EMBED_NODE_NAME]).toBeUndefined();
|
||||
|
||||
// The shared ydoc fragment was also rewritten clean (re-decode it).
|
||||
const reDecoded = TiptapTransformer.fromYdoc(doc, 'default');
|
||||
expect(hasHtmlEmbedNode(reDecoded)).toBe(false);
|
||||
});
|
||||
|
||||
it('toggle ON + admin store: htmlEmbed preserved in persisted content', async () => {
|
||||
const captured = await runStore('admin', buildYdoc(RICH_DOC), true);
|
||||
expect(captured.content).toBeDefined();
|
||||
expect(hasHtmlEmbedNode(captured.content)).toBe(true);
|
||||
expect(nodeTypeCounts(captured.content)[HTML_EMBED_NODE_NAME]).toBe(2);
|
||||
});
|
||||
|
||||
it('toggle ON + owner store: htmlEmbed preserved', async () => {
|
||||
const captured = await runStore('owner', buildYdoc(RICH_DOC), true);
|
||||
expect(hasHtmlEmbedNode(captured.content)).toBe(true);
|
||||
});
|
||||
|
||||
it('toggle OFF + admin store: stripped (feature disabled for everyone)', async () => {
|
||||
const captured = await runStore('admin', buildYdoc(RICH_DOC), false);
|
||||
expect(hasHtmlEmbedNode(captured.content)).toBe(false);
|
||||
});
|
||||
|
||||
it('toggle OFF + owner store: stripped', async () => {
|
||||
const captured = await runStore('owner', buildYdoc(RICH_DOC), false);
|
||||
expect(hasHtmlEmbedNode(captured.content)).toBe(false);
|
||||
});
|
||||
|
||||
it('toggle OFF + member store: stripped', async () => {
|
||||
const captured = await runStore('member', buildYdoc(RICH_DOC), false);
|
||||
expect(hasHtmlEmbedNode(captured.content)).toBe(false);
|
||||
});
|
||||
|
||||
it('unknown/empty role: fails closed (stripped)', async () => {
|
||||
expect(
|
||||
hasHtmlEmbedNode((await runStore(undefined, buildYdoc(RICH_DOC))).content),
|
||||
).toBe(false);
|
||||
expect(
|
||||
hasHtmlEmbedNode((await runStore(null, buildYdoc(RICH_DOC))).content),
|
||||
).toBe(false);
|
||||
expect(
|
||||
hasHtmlEmbedNode((await runStore('viewer', buildYdoc(RICH_DOC))).content),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('empty-fragment ydoc (no content) does not throw and persists no embed', async () => {
|
||||
const emptyDoc = buildYdoc({
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph' }],
|
||||
});
|
||||
// Non-admin path with an empty/embed-free fragment must be a no-op strip,
|
||||
// not throw.
|
||||
await expect(runStore('member', emptyDoc)).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -39,13 +39,6 @@ import {
|
||||
HISTORY_INTERVAL,
|
||||
} from '../constants';
|
||||
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
|
||||
import {
|
||||
hasHtmlEmbedNode,
|
||||
htmlEmbedAllowed,
|
||||
isHtmlEmbedFeatureEnabled,
|
||||
stripHtmlEmbedNodes,
|
||||
} from '../../common/helpers/prosemirror/html-embed.util';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
|
||||
@Injectable()
|
||||
export class PersistenceExtension implements Extension {
|
||||
@@ -66,7 +59,6 @@ export class PersistenceExtension implements Extension {
|
||||
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
|
||||
private readonly collabHistory: CollabHistoryService,
|
||||
private readonly transclusionService: TransclusionService,
|
||||
private readonly workspaceRepo: WorkspaceRepo,
|
||||
) {}
|
||||
|
||||
async onLoadDocument(data: onLoadDocumentPayload) {
|
||||
@@ -120,61 +112,7 @@ export class PersistenceExtension implements Extension {
|
||||
|
||||
const pageId = getPageId(documentName);
|
||||
|
||||
let tiptapJson = TiptapTransformer.fromYdoc(document, 'default');
|
||||
|
||||
// SECURITY (Variant C admin gate, collab WebSocket write path):
|
||||
// The persisted snapshot is the merged ydoc, which may contain an htmlEmbed
|
||||
// node inserted by ANY connected editor. htmlEmbed renders raw, unsanitized
|
||||
// JS in every reader's browser, so only workspace admins/owners may author
|
||||
// it. When the user whose store triggers this persist is not an admin, strip
|
||||
// every htmlEmbed node before it is written to the page row AND before the
|
||||
// ydoc state is re-encoded, so the node cannot be reintroduced by a
|
||||
// non-admin via the collab socket.
|
||||
// NOTE (residual risk): the gate is keyed to the storing connection's user.
|
||||
// If an admin already authored an htmlEmbed and a non-admin's later store
|
||||
// does not touch it, this strip would remove the admin's embed on that
|
||||
// non-admin store. This is intentionally conservative (fail closed): the
|
||||
// admin re-adds/keeps the node on their own next edit. A future refinement
|
||||
// could diff against the previously persisted admin-authored embeds.
|
||||
//
|
||||
// ACCEPTED RESIDUAL RISK (pre-persist broadcast window): this strip runs in
|
||||
// the debounced onStoreDocument, but hocuspocus broadcasts each inbound Yjs
|
||||
// update to connected clients immediately, so a non-admin's transient
|
||||
// htmlEmbed can execute in OTHER open editors' browsers in the brief window
|
||||
// before this persist strips it. The exposure is limited to concurrent
|
||||
// AUTHENTICATED space members who have the doc open with Edit rights
|
||||
// (semi-trusted) — anonymous public-share/readonly viewers do NOT open a
|
||||
// collab socket (ReadonlyPageEditor renders fetched, already-stripped
|
||||
// content; HocuspocusProvider is only used by the authenticated editable
|
||||
// page-editor), and the PERSISTED page row plus every share/readonly read
|
||||
// path are protected by this strip. The window is therefore accepted rather
|
||||
// than mitigated with an inbound beforeBroadcast strip.
|
||||
// Toggle-AND-admin gate: htmlEmbed survives only when the workspace feature
|
||||
// toggle is ON and the storing user is an admin/owner. OFF (default) =>
|
||||
// stripped for everyone (existing embeds get cleaned up on next save).
|
||||
const htmlEmbedEnabled = isHtmlEmbedFeatureEnabled(
|
||||
(await this.workspaceRepo.findById(context?.user?.workspaceId))?.settings,
|
||||
);
|
||||
if (!htmlEmbedAllowed(htmlEmbedEnabled, context?.user?.role)) {
|
||||
if (hasHtmlEmbedNode(tiptapJson)) {
|
||||
this.logger.warn(
|
||||
`Stripping htmlEmbed node(s) from collab store by user ${context?.user?.id} on ${documentName}`,
|
||||
);
|
||||
tiptapJson = stripHtmlEmbedNodes(tiptapJson);
|
||||
// Reflect the stripped content back into the shared ydoc so the node is
|
||||
// removed for all connected clients, not just the persisted row.
|
||||
const fragment = document.getXmlFragment('default');
|
||||
if (fragment.length > 0) {
|
||||
fragment.delete(0, fragment.length);
|
||||
}
|
||||
const cleanDoc = TiptapTransformer.toYdoc(
|
||||
tiptapJson,
|
||||
'default',
|
||||
tiptapExtensions,
|
||||
);
|
||||
Y.applyUpdate(document, Y.encodeStateAsUpdate(cleanDoc));
|
||||
}
|
||||
}
|
||||
const tiptapJson = TiptapTransformer.fromYdoc(document, 'default');
|
||||
|
||||
const ydocState = Buffer.from(Y.encodeStateAsUpdate(document));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user