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:
@@ -8,6 +8,11 @@ import {
|
||||
import { setYjsMark, updateYjsMarkAttribute, YjsSelection } from './yjs.util';
|
||||
import * as Y from 'yjs';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import {
|
||||
canAuthorHtmlEmbed,
|
||||
hasHtmlEmbedNode,
|
||||
stripHtmlEmbedNodes,
|
||||
} from '../common/helpers/prosemirror/html-embed.util';
|
||||
|
||||
export type CollabEventHandlers = ReturnType<
|
||||
CollaborationHandler['getHandlers']
|
||||
@@ -83,8 +88,25 @@ export class CollaborationHandler {
|
||||
user: User;
|
||||
},
|
||||
) => {
|
||||
const { prosemirrorJson, operation, user } = payload;
|
||||
const { operation, user } = payload;
|
||||
let { 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.
|
||||
if (!canAuthorHtmlEmbed(user?.role)) {
|
||||
if (hasHtmlEmbedNode(prosemirrorJson)) {
|
||||
this.logger.warn(
|
||||
`Stripping htmlEmbed node(s) from non-admin update by user ${user?.id} on ${documentName}`,
|
||||
);
|
||||
prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson);
|
||||
}
|
||||
}
|
||||
|
||||
await this.withYdocConnection(
|
||||
hocuspocus,
|
||||
documentName,
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
Drawio,
|
||||
Excalidraw,
|
||||
Embed,
|
||||
HtmlEmbed,
|
||||
Mention,
|
||||
Subpages,
|
||||
Highlight,
|
||||
@@ -102,6 +103,10 @@ export const tiptapExtensions = [
|
||||
Drawio,
|
||||
Excalidraw,
|
||||
Embed,
|
||||
// Registered server-side so the node survives schema parsing/serialization.
|
||||
// Authoring is gated to admins at the document WRITE paths (see
|
||||
// stripHtmlEmbedNodes usage in persistence/page services), NOT here.
|
||||
HtmlEmbed,
|
||||
Mention,
|
||||
Subpages,
|
||||
Columns,
|
||||
|
||||
@@ -39,6 +39,11 @@ import {
|
||||
HISTORY_INTERVAL,
|
||||
} from '../constants';
|
||||
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
|
||||
import {
|
||||
canAuthorHtmlEmbed,
|
||||
hasHtmlEmbedNode,
|
||||
stripHtmlEmbedNodes,
|
||||
} from '../../common/helpers/prosemirror/html-embed.util';
|
||||
|
||||
@Injectable()
|
||||
export class PersistenceExtension implements Extension {
|
||||
@@ -112,7 +117,56 @@ export class PersistenceExtension implements Extension {
|
||||
|
||||
const pageId = getPageId(documentName);
|
||||
|
||||
const tiptapJson = TiptapTransformer.fromYdoc(document, 'default');
|
||||
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.
|
||||
if (!canAuthorHtmlEmbed(context?.user?.role)) {
|
||||
if (hasHtmlEmbedNode(tiptapJson)) {
|
||||
this.logger.warn(
|
||||
`Stripping htmlEmbed node(s) from non-admin 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 ydocState = Buffer.from(Y.encodeStateAsUpdate(document));
|
||||
|
||||
let textContent = null;
|
||||
|
||||
229
apps/server/src/common/helpers/prosemirror/html-embed.spec.ts
Normal file
229
apps/server/src/common/helpers/prosemirror/html-embed.spec.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import {
|
||||
canAuthorHtmlEmbed,
|
||||
hasHtmlEmbedNode,
|
||||
stripHtmlEmbedNodes,
|
||||
} from './html-embed.util';
|
||||
import { htmlToJson, jsonToHtml } from '../../../collaboration/collaboration.util';
|
||||
import {
|
||||
decodeHtmlEmbedSource,
|
||||
encodeHtmlEmbedSource,
|
||||
} from '@docmost/editor-ext';
|
||||
|
||||
const findFirstChild = (json: any, type: string): any | undefined => {
|
||||
if (!json || typeof json !== 'object') return undefined;
|
||||
if (json.type === type) return json;
|
||||
if (Array.isArray(json.content)) {
|
||||
for (const child of json.content) {
|
||||
const found = findFirstChild(child, type);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
describe('stripHtmlEmbedNodes', () => {
|
||||
it('removes a top-level htmlEmbed node', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'before' }] },
|
||||
{ type: 'htmlEmbed', attrs: { source: '<script>alert(1)</script>' } },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'after' }] },
|
||||
],
|
||||
};
|
||||
|
||||
const result = stripHtmlEmbedNodes(doc);
|
||||
expect(hasHtmlEmbedNode(result)).toBe(false);
|
||||
// Other nodes are preserved.
|
||||
expect(result.content).toHaveLength(2);
|
||||
expect(result.content[0].content[0].text).toBe('before');
|
||||
expect(result.content[1].content[0].text).toBe('after');
|
||||
});
|
||||
|
||||
it('removes nested htmlEmbed nodes (e.g. inside columns)', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'columns',
|
||||
content: [
|
||||
{
|
||||
type: 'column',
|
||||
content: [
|
||||
{ type: 'htmlEmbed', attrs: { source: '<b>x</b>' } },
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'keep' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = stripHtmlEmbedNodes(doc);
|
||||
expect(hasHtmlEmbedNode(result)).toBe(false);
|
||||
const col = findFirstChild(result, 'column');
|
||||
expect(col.content).toHaveLength(1);
|
||||
expect(col.content[0].type).toBe('paragraph');
|
||||
});
|
||||
|
||||
it('does not mutate the input document', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [{ type: 'htmlEmbed', attrs: { source: 'x' } }],
|
||||
};
|
||||
stripHtmlEmbedNodes(doc);
|
||||
expect(doc.content).toHaveLength(1);
|
||||
expect(doc.content[0].type).toBe('htmlEmbed');
|
||||
});
|
||||
|
||||
it('leaves documents without htmlEmbed untouched', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] },
|
||||
],
|
||||
};
|
||||
expect(hasHtmlEmbedNode(doc)).toBe(false);
|
||||
const result = stripHtmlEmbedNodes(doc);
|
||||
expect(result).toEqual(doc);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAuthorHtmlEmbed', () => {
|
||||
it('allows owner and admin', () => {
|
||||
expect(canAuthorHtmlEmbed('owner')).toBe(true);
|
||||
expect(canAuthorHtmlEmbed('admin')).toBe(true);
|
||||
});
|
||||
it('denies member and unknown/empty roles', () => {
|
||||
expect(canAuthorHtmlEmbed('member')).toBe(false);
|
||||
expect(canAuthorHtmlEmbed(null)).toBe(false);
|
||||
expect(canAuthorHtmlEmbed(undefined)).toBe(false);
|
||||
expect(canAuthorHtmlEmbed('viewer')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Replicates the write-path decision used by every non-admin persistence guard
|
||||
// (collab store, single import, zip import, duplication, transclusion unsync):
|
||||
// if !canAuthorHtmlEmbed(role) && hasHtmlEmbedNode(json) -> strip, else keep.
|
||||
const applyAdminGate = (json: any, role: string | null | undefined) => {
|
||||
if (!canAuthorHtmlEmbed(role) && hasHtmlEmbedNode(json)) {
|
||||
return stripHtmlEmbedNodes(json);
|
||||
}
|
||||
return json;
|
||||
};
|
||||
|
||||
describe('admin-gate write-path decision (duplication / import / unsync)', () => {
|
||||
const docWithEmbed = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'keep' }] },
|
||||
{ type: 'htmlEmbed', attrs: { source: '<script>alert(1)</script>' } },
|
||||
],
|
||||
};
|
||||
|
||||
it('strips the embed for a non-admin (member) author', () => {
|
||||
const result = applyAdminGate(docWithEmbed, 'member');
|
||||
expect(hasHtmlEmbedNode(result)).toBe(false);
|
||||
expect(result.content).toHaveLength(1);
|
||||
expect(result.content[0].content[0].text).toBe('keep');
|
||||
});
|
||||
|
||||
it('strips the embed for unknown/empty roles', () => {
|
||||
expect(hasHtmlEmbedNode(applyAdminGate(docWithEmbed, null))).toBe(false);
|
||||
expect(hasHtmlEmbedNode(applyAdminGate(docWithEmbed, undefined))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(hasHtmlEmbedNode(applyAdminGate(docWithEmbed, 'viewer'))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the embed for an admin author', () => {
|
||||
const result = applyAdminGate(docWithEmbed, 'admin');
|
||||
expect(hasHtmlEmbedNode(result)).toBe(true);
|
||||
expect(result).toBe(docWithEmbed);
|
||||
});
|
||||
|
||||
it('keeps the embed for an owner author', () => {
|
||||
const result = applyAdminGate(docWithEmbed, 'owner');
|
||||
expect(hasHtmlEmbedNode(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('strips nested embeds (subtree/column duplication) for a non-admin', () => {
|
||||
const nested = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'columns',
|
||||
content: [
|
||||
{
|
||||
type: 'column',
|
||||
content: [
|
||||
{ type: 'htmlEmbed', attrs: { source: '<script>x</script>' } },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'ok' }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = applyAdminGate(nested, 'member');
|
||||
expect(hasHtmlEmbedNode(result)).toBe(false);
|
||||
const col = findFirstChild(result, 'column');
|
||||
expect(col.content).toHaveLength(1);
|
||||
expect(col.content[0].type).toBe('paragraph');
|
||||
});
|
||||
|
||||
it('leaves a non-admin doc without embeds untouched (no needless rewrite)', () => {
|
||||
const clean = {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] }],
|
||||
};
|
||||
const result = applyAdminGate(clean, 'member');
|
||||
expect(result).toBe(clean);
|
||||
});
|
||||
});
|
||||
|
||||
describe('htmlEmbed source base64 codec', () => {
|
||||
it('round-trips arbitrary source including UTF-8', () => {
|
||||
const source = '<script>console.log("héllo → 世界")</script>';
|
||||
const encoded = encodeHtmlEmbedSource(source);
|
||||
expect(encoded).not.toContain('<');
|
||||
expect(decodeHtmlEmbedSource(encoded)).toBe(source);
|
||||
});
|
||||
});
|
||||
|
||||
describe('htmlEmbed node HTML <-> JSON round-trip', () => {
|
||||
it('preserves the raw source through HTML -> JSON', () => {
|
||||
const source = '<script>track("page")</script><style>.a{color:red}</style>';
|
||||
const encoded = encodeHtmlEmbedSource(source);
|
||||
const html = `<div data-type="htmlEmbed" data-source="${encoded}"></div>`;
|
||||
|
||||
const json = htmlToJson(html);
|
||||
const node = findFirstChild(json, 'htmlEmbed');
|
||||
expect(node).toBeDefined();
|
||||
expect(node.attrs.source).toBe(source);
|
||||
});
|
||||
|
||||
it('round-trips JSON -> HTML -> JSON keeping the source', () => {
|
||||
const source = '<div onclick="x()">raw & markup</div>';
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [{ type: 'htmlEmbed', attrs: { source } }],
|
||||
};
|
||||
|
||||
const html = jsonToHtml(json);
|
||||
// The static HTML carries the encoded source but does NOT inline the raw
|
||||
// markup (it must not be an injection vector by itself).
|
||||
expect(html).toContain('data-type="htmlEmbed"');
|
||||
expect(html).not.toContain('onclick');
|
||||
|
||||
const back = htmlToJson(html);
|
||||
const node = findFirstChild(back, 'htmlEmbed');
|
||||
expect(node).toBeDefined();
|
||||
expect(node.attrs.source).toBe(source);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { JSONContent } from '@tiptap/core';
|
||||
|
||||
export const HTML_EMBED_NODE_NAME = 'htmlEmbed';
|
||||
|
||||
/**
|
||||
* Recursively remove every `htmlEmbed` node from a ProseMirror JSON document.
|
||||
*
|
||||
* SECURITY: `htmlEmbed` renders raw, unsanitized HTML/CSS/JS in the wiki origin
|
||||
* (stored-XSS by design, Variant C). Only workspace admins/owners are allowed to
|
||||
* author it. This helper is the server-side enforcement primitive: every WRITE
|
||||
* path that may persist content from a NON-admin caller must run the incoming
|
||||
* document through this function so a non-admin cannot smuggle the node in via
|
||||
* the collab socket, the REST/MCP/AI content-update path, paste, or import.
|
||||
*
|
||||
* Returns a NEW document; the input is not mutated. If the input is not a valid
|
||||
* doc object it is returned unchanged (callers persist what they were given).
|
||||
*/
|
||||
export function stripHtmlEmbedNodes<T = JSONContent>(pmJson: T): T {
|
||||
if (!pmJson || typeof pmJson !== 'object') {
|
||||
return pmJson;
|
||||
}
|
||||
|
||||
const node = pmJson as unknown as JSONContent;
|
||||
|
||||
if (Array.isArray(node.content)) {
|
||||
const filtered: JSONContent[] = [];
|
||||
for (const child of node.content) {
|
||||
// Drop any htmlEmbed child outright.
|
||||
if (child && child.type === HTML_EMBED_NODE_NAME) {
|
||||
continue;
|
||||
}
|
||||
// Recurse so nested htmlEmbed nodes (e.g. inside columns/callouts) are
|
||||
// also removed.
|
||||
filtered.push(stripHtmlEmbedNodes(child));
|
||||
}
|
||||
return { ...node, content: filtered } as unknown as T;
|
||||
}
|
||||
|
||||
return { ...node } as unknown as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the document contains at least one `htmlEmbed` node anywhere
|
||||
* in its tree. Useful to decide whether a strip pass actually changed anything
|
||||
* (e.g. for logging a rejected non-admin embed attempt).
|
||||
*/
|
||||
export function hasHtmlEmbedNode(pmJson: unknown): boolean {
|
||||
if (!pmJson || typeof pmJson !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const node = pmJson as JSONContent;
|
||||
if (node.type === HTML_EMBED_NODE_NAME) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(node.content)) {
|
||||
return node.content.some((child) => hasHtmlEmbedNode(child));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the workspace user role to whether it may author `htmlEmbed` nodes.
|
||||
* Owners and admins are trusted; everyone else (member, and any unknown role)
|
||||
* is not. Kept here so every write path shares one definition of "trusted".
|
||||
*/
|
||||
export function canAuthorHtmlEmbed(role: string | null | undefined): boolean {
|
||||
return role === 'owner' || role === 'admin';
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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