* — directly, bypassing the editor's `` comment marker.
*
- * This exercises the REAL server import conversion path that ImportService uses
+ * The block renders inside a sandboxed iframe, so this is not an XSS surface;
+ * this exercises the REAL server import conversion path that ImportService uses
* (`markdownToHtml` then `htmlToJson`; `processHTML` adds only a cheerio
* link/iframe normalize pass which does not touch htmlEmbed divs) and asserts
- * the ACTUAL behaviour so we know whether the strip gate can be bypassed.
- *
- * FINDING (documented): the raw embed div DOES round-trip through marked +
- * htmlToJson into a real `htmlEmbed` node, so `hasHtmlEmbedNode` returns true and
- * `stripHtmlEmbedNodes` removes it. The serialized-form bypass is therefore
- * detectable and STRIPPABLE — the write-path gate covers it.
+ * that such a node is DETECTED and STRIPPABLE — so the share read path's
+ * master-toggle strip can remove it when the workspace toggle is OFF.
*/
describe('htmlEmbed smuggled via the raw serialized div in imported markdown/HTML', () => {
it('round-trips through markdownToHtml -> htmlToJson and is DETECTED (base64 data-source)', async () => {
@@ -38,7 +35,7 @@ describe('htmlEmbed smuggled via the raw serialized div in imported markdown/HTM
// The div parses into a real htmlEmbed node carrying the decoded source.
expect(hasHtmlEmbedNode(json)).toBe(true);
- // Because it is detected, the write-path gate can strip it for non-admins.
+ // Because it is detected, the share master-toggle strip can remove it.
const stripped = stripHtmlEmbedNodes(json);
expect(hasHtmlEmbedNode(stripped)).toBe(false);
// Surrounding non-embed content is retained.
diff --git a/apps/server/src/common/helpers/prosemirror/html-embed.spec.ts b/apps/server/src/common/helpers/prosemirror/html-embed.spec.ts
index 28a59ea3..58a7cb64 100644
--- a/apps/server/src/common/helpers/prosemirror/html-embed.spec.ts
+++ b/apps/server/src/common/helpers/prosemirror/html-embed.spec.ts
@@ -1,7 +1,5 @@
import {
- canAuthorHtmlEmbed,
hasHtmlEmbedNode,
- htmlEmbedAllowed,
isHtmlEmbedFeatureEnabled,
stripHtmlEmbedNodes,
} from './html-embed.util';
@@ -190,19 +188,6 @@ describe('hasHtmlEmbedNode (root/odd-shape detection)', () => {
});
});
-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);
- });
-});
-
describe('isHtmlEmbedFeatureEnabled', () => {
it('is true only when settings.htmlEmbed === true', () => {
expect(isHtmlEmbedFeatureEnabled({ htmlEmbed: true })).toBe(true);
@@ -217,52 +202,22 @@ describe('isHtmlEmbedFeatureEnabled', () => {
});
});
-describe('htmlEmbedAllowed (toggle AND admin)', () => {
- it('toggle OFF + admin/owner => not allowed (feature disabled for everyone)', () => {
- expect(htmlEmbedAllowed(false, 'admin')).toBe(false);
- expect(htmlEmbedAllowed(false, 'owner')).toBe(false);
- });
- it('toggle OFF + member => not allowed', () => {
- expect(htmlEmbedAllowed(false, 'member')).toBe(false);
- });
- it('toggle ON + admin/owner => allowed', () => {
- expect(htmlEmbedAllowed(true, 'admin')).toBe(true);
- expect(htmlEmbedAllowed(true, 'owner')).toBe(true);
- });
- it('toggle ON + member/unknown => not allowed', () => {
- expect(htmlEmbedAllowed(true, 'member')).toBe(false);
- expect(htmlEmbedAllowed(true, null)).toBe(false);
- expect(htmlEmbedAllowed(true, undefined)).toBe(false);
- expect(htmlEmbedAllowed(true, 'viewer')).toBe(false);
- });
-});
-
-// NOTE: a previous revision of this file re-implemented the write-path admin
-// gate as a local `applyAdminGate` stand-in and asserted against THAT. A
-// deleted/misplaced real guard would have kept those green. The stand-in is
-// removed. The collab store, REST/MCP update, and transclusion-unsync paths are
-// now tested against their REAL code in:
-// - collaboration/extensions/persistence.extension.html-embed.spec.ts
-// - collaboration/collaboration.handler.html-embed.spec.ts
-// - core/page/transclusion/spec/transclusion-unsync-html-embed.spec.ts
-// - core/page/services/page-service-html-embed-identity.spec.ts (create/dup)
-// - integrations/import/services/import-html-embed-identity.spec.ts (import)
+// The htmlEmbed node renders inside a sandboxed iframe, so the per-write role
+// gate has been removed. `stripHtmlEmbedNodes` + `isHtmlEmbedFeatureEnabled`
+// remain ONLY to honor the workspace master toggle on the anonymous public-share
+// read path — tested against the real share code in:
+// - core/share/share-html-embed.spec.ts
//
-// The case below stays here because it asserts a REAL parse path
-// (htmlToJson, the markdown/html create format) feeding the REAL helpers — not a
-// re-implemented gate.
-describe('htmlEmbed smuggled via the markdown/html form (real parse + real helpers)', () => {
- it('the parsed node is detected and stripped by the real helpers', () => {
- // The markdown/html create formats decode to the same htmlEmbed node, so the
- // gate (run on the parsed JSON) covers them identically.
- const source = '';
+// The case below asserts that the REAL parse path (htmlToJson, the markdown/html
+// form) produces an htmlEmbed node the master-toggle strip can detect & remove.
+describe('htmlEmbed via the markdown/html form (real parse + real strip helper)', () => {
+ it('the parsed node is detected and stripped by the real helper', () => {
+ const source = '';
const encoded = encodeHtmlEmbedSource(source);
const html = `
`;
const parsed = htmlToJson(html);
expect(hasHtmlEmbedNode(parsed)).toBe(true);
- // A non-admin role gates to strip via the real helpers.
- expect(canAuthorHtmlEmbed('member')).toBe(false);
const stripped = stripHtmlEmbedNodes(parsed);
expect(hasHtmlEmbedNode(stripped)).toBe(false);
});
diff --git a/apps/server/src/common/helpers/prosemirror/html-embed.util.ts b/apps/server/src/common/helpers/prosemirror/html-embed.util.ts
index f1d0b6e5..d4e3cf35 100644
--- a/apps/server/src/common/helpers/prosemirror/html-embed.util.ts
+++ b/apps/server/src/common/helpers/prosemirror/html-embed.util.ts
@@ -5,12 +5,12 @@ 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.
+ * The `htmlEmbed` node renders inside a SANDBOXED iframe (no `allow-same-origin`)
+ * on the client, so its content cannot touch the viewer's session/cookies/API —
+ * it is NOT a stored-XSS surface. This helper is retained ONLY to honor the
+ * workspace master toggle (`settings.htmlEmbed`) on the anonymous public-share
+ * read path: an anonymous viewer cannot read the workspace toggle, so the server
+ * strips the block when the toggle is OFF before serving shared content.
*
* 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).
@@ -41,8 +41,8 @@ export function stripHtmlEmbedNodes
(pmJson: T): 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).
+ * in its tree. Useful to decide whether a strip pass on the share read path
+ * actually changed anything.
*/
export function hasHtmlEmbedNode(pmJson: unknown): boolean {
if (!pmJson || typeof pmJson !== 'object') {
@@ -59,38 +59,9 @@ export function hasHtmlEmbedNode(pmJson: unknown): boolean {
}
/**
- * 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';
-}
-
-/**
- * Combined write-path gate for the htmlEmbed feature.
- *
- * htmlEmbed is allowed in a document only when the workspace feature toggle is
- * ON and the authoring/saving user is a workspace admin/owner. OFF (default) =>
- * stripped for EVERYONE, including admins (the feature is disabled).
- *
- * `featureEnabled` is read from the workspace settings for the relevant write
- * (`workspace.settings?.htmlEmbed === true`). Every WRITE path that may persist
- * htmlEmbed content must gate on this combined predicate, so that turning the
- * toggle OFF strips existing embeds on the next save and prevents new ones from
- * being persisted regardless of role.
- */
-export function htmlEmbedAllowed(
- featureEnabled: boolean,
- role: string | null | undefined,
-): boolean {
- return featureEnabled === true && canAuthorHtmlEmbed(role);
-}
-
-/**
- * Read the workspace-level htmlEmbed feature toggle from a workspace's settings
- * jsonb. ABSENT/non-true => OFF (the default). Kept here so every server write
- * path resolves the toggle the same way.
+ * Read the workspace-level htmlEmbed master toggle from a workspace's settings
+ * jsonb. ABSENT/non-true => OFF (the default). Kept here so the share read path
+ * resolves the toggle the same way it is persisted.
*/
export function isHtmlEmbedFeatureEnabled(
settings: unknown | null | undefined,
diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts
index 968480c9..fd5c866e 100644
--- a/apps/server/src/core/page/page.controller.ts
+++ b/apps/server/src/core/page/page.controller.ts
@@ -237,9 +237,6 @@ export class PageController {
user.id,
workspace.id,
createPageDto,
- // Pass the caller's workspace role so create() can enforce the htmlEmbed
- // admin gate (non-admins cannot author raw-JS embeds).
- user.role,
provenance,
);
diff --git a/apps/server/src/core/page/services/page-service-html-embed-identity.spec.ts b/apps/server/src/core/page/services/page-service-html-embed-identity.spec.ts
deleted file mode 100644
index f23d565a..00000000
--- a/apps/server/src/core/page/services/page-service-html-embed-identity.spec.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import { readFileSync } from 'node:fs';
-import { join } from 'node:path';
-import {
- hasHtmlEmbedNode,
- htmlEmbedAllowed,
- stripHtmlEmbedNodes,
-} from '../../../common/helpers/prosemirror/html-embed.util';
-
-// PageService.create() and duplicatePage() guards.
-//
-// page.service.ts cannot be unit-LOADED under the server's jest config (a
-// transitive ESM dep, @sindresorhus/slugify, is not in transformIgnorePatterns),
-// so we cover the two load-bearing properties at the strongest feasible layer:
-//
-// (1) BEHAVIOR — using the REAL html-embed helpers, replay the exact predicate
-// each path applies: non-admin/unknown role -> strip, admin/owner -> keep.
-//
-// (2) IDENTITY — source-pin which role each path reads (create: the `callerRole`
-// param threaded from the request; duplicate: `authUser.role`), so a
-// refactor that drops the guard or reads the wrong role trips the test.
-// This is what replaces the removed `applyAdminGate` stand-in for these
-// two entrypoints.
-
-const docWithEmbed = () => ({
- type: 'doc',
- content: [
- { type: 'paragraph', content: [{ type: 'text', text: 'body' }] },
- { type: 'htmlEmbed', attrs: { source: '' } },
- ],
-});
-
-// The real predicate both paths apply (see SECURITY blocks in page.service.ts):
-// toggle AND admin.
-function applyGate(
- json: any,
- featureEnabled: boolean,
- role: string | null | undefined,
-) {
- if (!htmlEmbedAllowed(featureEnabled, role) && hasHtmlEmbedNode(json)) {
- return stripHtmlEmbedNodes(json);
- }
- return json;
-}
-
-describe('page create/duplicate gate decision (real helpers)', () => {
- it('toggle ON + non-admin (member) strips', () => {
- const result = applyGate(docWithEmbed(), true, 'member');
- expect(hasHtmlEmbedNode(result)).toBe(false);
- expect(result.content).toHaveLength(1);
- expect(result.content[0].content[0].text).toBe('body');
- });
-
- it('toggle ON + unknown/empty role fails closed (strips)', () => {
- for (const role of [null, undefined, 'viewer'] as const) {
- expect(hasHtmlEmbedNode(applyGate(docWithEmbed(), true, role))).toBe(
- false,
- );
- }
- });
-
- it('toggle ON + admin/owner keep', () => {
- expect(hasHtmlEmbedNode(applyGate(docWithEmbed(), true, 'admin'))).toBe(
- true,
- );
- expect(hasHtmlEmbedNode(applyGate(docWithEmbed(), true, 'owner'))).toBe(
- true,
- );
- });
-
- it('toggle OFF strips for everyone (admin/owner/member)', () => {
- for (const role of ['admin', 'owner', 'member'] as const) {
- expect(hasHtmlEmbedNode(applyGate(docWithEmbed(), false, role))).toBe(
- false,
- );
- }
- });
-});
-
-const SRC = readFileSync(join(__dirname, 'page.service.ts'), 'utf-8');
-
-describe('page create/duplicate gate identity is pinned (source contract)', () => {
- it('create() gates on toggle AND the caller role param before deriving content/ydoc', () => {
- // create() receives the caller's workspace role as `callerRole` and gates on
- // the combined toggle-AND-admin predicate; the embed must be stripped BEFORE
- // insertPage.
- expect(SRC).toMatch(
- /!htmlEmbedAllowed\(\s*htmlEmbedEnabled\s*,\s*callerRole\s*\)\s*&&\s*hasHtmlEmbedNode\(\s*prosemirrorJson\s*\)/,
- );
- expect(SRC).toContain('prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson)');
- });
-
- it('duplicatePage() gates on toggle AND the duplicating user role (authUser.role)', () => {
- expect(SRC).toMatch(
- /!htmlEmbedAllowed\(\s*htmlEmbedEnabled\s*,\s*authUser\.role\s*\)\s*&&\s*hasHtmlEmbedNode\(\s*prosemirrorJson\s*\)/,
- );
- });
-
- it('both paths resolve the toggle from the workspace settings', () => {
- expect(SRC).toContain('isHtmlEmbedFeatureEnabled(');
- expect(SRC).toContain('this.workspaceRepo.findById(');
- });
-});
diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts
index 5373801d..ba904d87 100644
--- a/apps/server/src/core/page/services/page.service.ts
+++ b/apps/server/src/core/page/services/page.service.ts
@@ -30,13 +30,6 @@ import {
isAttachmentNode,
removeMarkTypeFromDoc,
} from '../../../common/helpers/prosemirror/utils';
-import {
- hasHtmlEmbedNode,
- htmlEmbedAllowed,
- isHtmlEmbedFeatureEnabled,
- stripHtmlEmbedNodes,
-} from '../../../common/helpers/prosemirror/html-embed.util';
-import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import {
htmlToJson,
jsonToNode,
@@ -81,7 +74,6 @@ export class PageService {
private collaborationGateway: CollaborationGateway,
private readonly watcherService: WatcherService,
private readonly transclusionService: TransclusionService,
- private readonly workspaceRepo: WorkspaceRepo,
) {}
async findById(
@@ -101,10 +93,6 @@ export class PageService {
userId: string,
workspaceId: string,
createPageDto: CreatePageDto,
- // Workspace role of the caller. Used to enforce the htmlEmbed admin gate on
- // the create write path (see below). Optional/typed loosely so unknown or
- // missing roles fall through to the non-admin (strip) branch by default.
- callerRole?: string | null,
// Optional agent-edit provenance (from the signed access claim). When the
// actor is 'agent', stamp the page's source marker so a freshly created page
// shows it was created by the AI agent (§14 N2) — create goes through REST,
@@ -135,36 +123,11 @@ export class PageService {
let ydoc = undefined;
if (createPageDto?.content && createPageDto?.format) {
- let prosemirrorJson = await this.parseProsemirrorContent(
+ const prosemirrorJson = await this.parseProsemirrorContent(
createPageDto.content,
createPageDto.format,
);
- // SECURITY (Variant C admin gate, plain page-create write path):
- // create() builds content/textContent/ydoc directly and persists them via
- // insertPage, bypassing the collab onStoreDocument strip. htmlEmbed renders
- // raw, unsanitized JS in readers' browsers, so only workspace admins/owners
- // may author it. The create controller requires only space Edit, so a
- // regular member could otherwise POST a doc (json, or the markdown/html
- // forms that parse to the same node) containing an
- // htmlEmbed and store XSS for every reader. Strip every htmlEmbed node when
- // the caller is not an admin, BEFORE deriving textContent/ydoc/insert.
- // The gate is toggle-AND-admin: htmlEmbed survives only when the workspace
- // feature toggle is ON and the caller is an admin/owner. OFF (default) =>
- // stripped for everyone. Cheap settings read keyed to the workspace.
- const htmlEmbedEnabled = isHtmlEmbedFeatureEnabled(
- (await this.workspaceRepo.findById(workspaceId))?.settings,
- );
- if (
- !htmlEmbedAllowed(htmlEmbedEnabled, callerRole) &&
- hasHtmlEmbedNode(prosemirrorJson)
- ) {
- this.logger.warn(
- `Stripping htmlEmbed node(s) from page creation by user ${userId} (space ${createPageDto.spaceId})`,
- );
- prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson);
- }
-
content = prosemirrorJson;
textContent = jsonToText(prosemirrorJson);
ydoc = createYdocFromJson(prosemirrorJson);
@@ -627,12 +590,6 @@ export class PageService {
const attachmentMap = new Map();
- // Resolve the htmlEmbed toggle ONCE for the workspace; the per-page gate
- // below is toggle-AND-admin (OFF default => stripped for everyone).
- const htmlEmbedEnabled = isHtmlEmbedFeatureEnabled(
- (await this.workspaceRepo.findById(rootPage.workspaceId))?.settings,
- );
-
const insertablePages: InsertablePage[] = await Promise.all(
pages.map(async (page) => {
const pageContent = getProsemirrorContent(page.content);
@@ -744,25 +701,7 @@ export class PageService {
}
});
- 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 (
- !htmlEmbedAllowed(htmlEmbedEnabled, authUser.role) &&
- hasHtmlEmbedNode(prosemirrorJson)
- ) {
- this.logger.warn(
- `Stripping htmlEmbed node(s) from page duplication by user ${authUser.id} (source page ${page.id})`,
- );
- prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson);
- }
+ const prosemirrorJson = prosemirrorDoc.toJSON();
// Add "Copy of " prefix to the root page title only for duplicates in same space
let title = page.title;
diff --git a/apps/server/src/core/page/transclusion/spec/page-template-access.spec.ts b/apps/server/src/core/page/transclusion/spec/page-template-access.spec.ts
index 2f37eb97..ef15bd18 100644
--- a/apps/server/src/core/page/transclusion/spec/page-template-access.spec.ts
+++ b/apps/server/src/core/page/transclusion/spec/page-template-access.spec.ts
@@ -68,7 +68,6 @@ describe('TransclusionService — template access core (real filter)', () => {
{} as any, // attachmentRepo
{} as any, // storageService
{} as any, // pageAccessService
- {} as any, // workspaceRepo
);
return { service, db, pageRepo, spaceMemberRepo, pagePermissionRepo };
@@ -222,7 +221,6 @@ describe('TransclusionService.filterViewerAccessiblePageIds — AND ordering (co
{} as any, // attachmentRepo
{} as any, // storageService
{} as any, // pageAccessService
- {} as any, // workspaceRepo
);
return { service, filterAccessiblePageIds };
@@ -319,7 +317,6 @@ describe('TransclusionService.syncPageTemplateReferences — workspace scoping',
{} as any, // attachmentRepo
{} as any, // storageService
{} as any, // pageAccessService
- {} as any, // workspaceRepo
);
return {
@@ -464,7 +461,6 @@ describe('TransclusionService.insertTemplateReferencesForPages — per-workspace
{} as any, // attachmentRepo
{} as any, // storageService
{} as any, // pageAccessService
- {} as any, // workspaceRepo
);
return { service, insertMany };
}
diff --git a/apps/server/src/core/page/transclusion/spec/page-template-lookup.spec.ts b/apps/server/src/core/page/transclusion/spec/page-template-lookup.spec.ts
index fbcd9486..8a8718b2 100644
--- a/apps/server/src/core/page/transclusion/spec/page-template-lookup.spec.ts
+++ b/apps/server/src/core/page/transclusion/spec/page-template-lookup.spec.ts
@@ -35,7 +35,6 @@ describe('TransclusionService.lookupTemplate (access mapping)', () => {
{} as any, // attachmentRepo
{} as any, // storageService
{} as any, // pageAccessService
- {} as any, // workspaceRepo
);
jest
diff --git a/apps/server/src/core/page/transclusion/spec/transclusion-unsync-html-embed.spec.ts b/apps/server/src/core/page/transclusion/spec/transclusion-unsync-html-embed.spec.ts
deleted file mode 100644
index 4d149369..00000000
--- a/apps/server/src/core/page/transclusion/spec/transclusion-unsync-html-embed.spec.ts
+++ /dev/null
@@ -1,145 +0,0 @@
-import { TransclusionService } from '../transclusion.service';
-import { hasHtmlEmbedNode } from '../../../../common/helpers/prosemirror/html-embed.util';
-
-// Exercises the REAL TransclusionService.unsyncReference htmlEmbed admin gate.
-// unsync returns a source snapshot the client materializes into the reference
-// page; a non-admin must never receive an embed payload to re-persist. The gate
-// reads `user.role` and strips before returning. All repos / access checks are
-// mocked so the REAL gate logic runs end-to-end. Complements the existing
-// transclusion specs (rewriteAttachmentsForUnsync, controller).
-
-const WS = 'ws-1';
-const REF_PAGE = 'ref-1';
-const SRC_PAGE = 'src-1';
-const TX_ID = 'tx-1';
-
-const sourceContentWithEmbed = () => ({
- type: 'doc',
- content: [
- { type: 'paragraph', content: [{ type: 'text', text: 'snapshot body' }] },
- { type: 'htmlEmbed', attrs: { source: '' } },
- ],
-});
-
-function buildService(featureEnabled = true) {
- const pageRepo = {
- findById: jest.fn(async (id: string) => ({
- id,
- workspaceId: WS,
- spaceId: 'space-1',
- deletedAt: null,
- })),
- };
- const pageTransclusionsRepo = {
- findByPageAndTransclusion: jest.fn(async () => ({
- content: sourceContentWithEmbed(),
- })),
- };
- const pageTransclusionReferencesRepo = {
- deleteOne: jest.fn(async () => undefined),
- };
- const attachmentRepo = { findByIds: jest.fn(async () => []) };
- const storageService = { copy: jest.fn(async () => undefined) };
- const pageAccessService = {
- validateCanEdit: jest.fn(async () => undefined),
- validateCanView: jest.fn(async () => undefined),
- };
- // Workspace settings read used by the toggle-AND-admin gate.
- const workspaceRepo = {
- findById: jest.fn(async () => ({
- id: WS,
- settings: { htmlEmbed: featureEnabled },
- })),
- };
-
- const service = new TransclusionService(
- {} as any, // db (unused on this path)
- pageTransclusionsRepo as any,
- pageTransclusionReferencesRepo as any,
- {} as any, // pageTemplateReferencesRepo (unused on this path)
- pageRepo as any,
- {} as any, // pagePermissionRepo (unused)
- {} as any, // spaceMemberRepo (unused)
- attachmentRepo as any,
- storageService as any,
- pageAccessService as any,
- workspaceRepo as any,
- );
- return service;
-}
-
-function userWithRole(role: string | null | undefined) {
- return { id: 'u1', workspaceId: WS, role } as any;
-}
-
-describe('TransclusionService.unsyncReference htmlEmbed admin gate (real code)', () => {
- it('non-admin (member): returned content has htmlEmbed stripped', async () => {
- const service = buildService();
- const { content } = await service.unsyncReference(
- REF_PAGE,
- SRC_PAGE,
- TX_ID,
- userWithRole('member'),
- );
- expect(hasHtmlEmbedNode(content)).toBe(false);
- // Non-embed content is preserved.
- expect(JSON.stringify(content)).toContain('snapshot body');
- });
-
- it('unknown/empty role: fails closed (stripped)', async () => {
- for (const role of [undefined, null, 'viewer'] as const) {
- const service = buildService();
- const { content } = await service.unsyncReference(
- REF_PAGE,
- SRC_PAGE,
- TX_ID,
- userWithRole(role),
- );
- expect(hasHtmlEmbedNode(content)).toBe(false);
- }
- });
-
- it('toggle ON + admin: returned content keeps the htmlEmbed', async () => {
- const service = buildService(true);
- const { content } = await service.unsyncReference(
- REF_PAGE,
- SRC_PAGE,
- TX_ID,
- userWithRole('admin'),
- );
- expect(hasHtmlEmbedNode(content)).toBe(true);
- });
-
- it('toggle ON + owner: returned content keeps the htmlEmbed', async () => {
- const service = buildService(true);
- const { content } = await service.unsyncReference(
- REF_PAGE,
- SRC_PAGE,
- TX_ID,
- userWithRole('owner'),
- );
- expect(hasHtmlEmbedNode(content)).toBe(true);
- });
-
- it('toggle OFF + admin: stripped (feature disabled for everyone)', async () => {
- const service = buildService(false);
- const { content } = await service.unsyncReference(
- REF_PAGE,
- SRC_PAGE,
- TX_ID,
- userWithRole('admin'),
- );
- expect(hasHtmlEmbedNode(content)).toBe(false);
- });
-
- it('toggle OFF + member: stripped', async () => {
- const service = buildService(false);
- const { content } = await service.unsyncReference(
- REF_PAGE,
- SRC_PAGE,
- TX_ID,
- userWithRole('member'),
- );
- expect(hasHtmlEmbedNode(content)).toBe(false);
- });
-});
diff --git a/apps/server/src/core/page/transclusion/transclusion.service.ts b/apps/server/src/core/page/transclusion/transclusion.service.ts
index f8f3b464..8e4ccf87 100644
--- a/apps/server/src/core/page/transclusion/transclusion.service.ts
+++ b/apps/server/src/core/page/transclusion/transclusion.service.ts
@@ -33,13 +33,6 @@ import {
import { jsonToNode } from '../../../collaboration/collaboration.util';
import { Page, User } from '@docmost/db/types/entity.types';
import { PageAccessService } from '../page-access/page-access.service';
-import {
- hasHtmlEmbedNode,
- htmlEmbedAllowed,
- isHtmlEmbedFeatureEnabled,
- stripHtmlEmbedNodes,
-} from '../../../common/helpers/prosemirror/html-embed.util';
-import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
type ReferencingPageInfo = {
id: string;
@@ -65,7 +58,6 @@ export class TransclusionService {
private readonly attachmentRepo: AttachmentRepo,
private readonly storageService: StorageService,
private readonly pageAccessService: PageAccessService,
- private readonly workspaceRepo: WorkspaceRepo,
) {}
async syncPageTransclusions(
@@ -753,24 +745,6 @@ 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.
- const htmlEmbedEnabled = isHtmlEmbedFeatureEnabled(
- (await this.workspaceRepo.findById(user.workspaceId))?.settings,
- );
- if (!htmlEmbedAllowed(htmlEmbedEnabled, user.role) && hasHtmlEmbedNode(content)) {
- this.logger.warn(
- `Stripping htmlEmbed node(s) from transclusion unsync by user ${user.id} (reference page ${referencePageId}, source page ${sourcePageId})`,
- );
- content = stripHtmlEmbedNodes(content);
- }
-
return { content };
}
}
diff --git a/apps/server/src/core/share/share-html-embed.spec.ts b/apps/server/src/core/share/share-html-embed.spec.ts
index 162ba4ae..49f98c55 100644
--- a/apps/server/src/core/share/share-html-embed.spec.ts
+++ b/apps/server/src/core/share/share-html-embed.spec.ts
@@ -1,12 +1,14 @@
import { ShareService } from './share.service';
import { hasHtmlEmbedNode } from '../../common/helpers/prosemirror/html-embed.util';
-// Exercises the REAL ShareService server-authoritative htmlEmbed kill-switch for
-// shared content. An anonymous public-share viewer cannot read the per-workspace
-// htmlEmbed toggle, so the SERVER must decide what to serve: when the toggle is
-// OFF, htmlEmbed nodes are stripped from the shared doc; when ON they are kept so
-// the read-only client executes them. All repos / token service are mocked so the
-// real prepareContentForShare logic runs end-to-end via getSharedPage.
+// Exercises the REAL ShareService server-authoritative htmlEmbed master toggle
+// for shared content. The block renders inside a sandboxed iframe (harmless), so
+// this is NOT an XSS guard — it is the master-toggle enforcement for anonymous
+// shares: an anonymous public-share viewer cannot read the per-workspace
+// htmlEmbed toggle, so the SERVER must decide what to serve. When the toggle is
+// OFF, htmlEmbed nodes are stripped from the shared doc; when ON they are served
+// and rendered in their sandboxed frame. All repos / token service are mocked so
+// the real prepareContentForShare logic runs end-to-end via getSharedPage.
const WS = 'ws-1';
const PAGE = 'page-1';
diff --git a/apps/server/src/core/share/share-seo.controller.ts b/apps/server/src/core/share/share-seo.controller.ts
index 51967ada..1f159416 100644
--- a/apps/server/src/core/share/share-seo.controller.ts
+++ b/apps/server/src/core/share/share-seo.controller.ts
@@ -84,10 +84,24 @@ export class ShareSeoController {
.join('\n ');
const html = fs.readFileSync(indexFilePath, 'utf8');
- const transformedHtml = html
+ let transformedHtml = html
.replace(/[\s\S]*?<\/title>/i, `${metaTitle}`)
.replace(metaTagVar, metaTags);
+ // Deliberate same-origin tracker surface: this is the ONE place where an
+ // admin-authored analytics/tracker snippet (settings.trackerHead) is
+ // injected verbatim into the page origin. It is admin-only (writable only
+ // via the admin-gated workspace settings) and applies to PUBLIC SHARE
+ // pages only. It is trusted content, so it is NOT escaped. The htmlEmbed
+ // block itself is sandboxed and is the safe surface for everyone else.
+ const trackerHead = (workspace?.settings as any)?.trackerHead;
+ if (typeof trackerHead === 'string' && trackerHead.trim().length > 0) {
+ transformedHtml = transformedHtml.replace(
+ '',
+ `${trackerHead}\n`,
+ );
+ }
+
res.type('text/html').send(transformedHtml);
}
}
diff --git a/apps/server/src/core/share/share.service.ts b/apps/server/src/core/share/share.service.ts
index 846cc96a..036e5dda 100644
--- a/apps/server/src/core/share/share.service.ts
+++ b/apps/server/src/core/share/share.service.ts
@@ -470,12 +470,14 @@ export class ShareService {
* not leak structure (existence, location, count, resolved state, or
* comment ids) to public viewers.
*
- * 3. Strip `htmlEmbed` nodes when the workspace feature toggle is OFF. This
- * makes the toggle a SERVER-AUTHORITATIVE kill-switch for shared content:
- * when OFF the embed is never served to the anonymous viewer (who can't
- * read the per-workspace toggle), when ON the embed is served so the
- * read-only client executes it. `htmlEmbedEnabled` is resolved fail-closed
- * by the callers (missing workspace => OFF => strip).
+ * 3. Strip `htmlEmbed` nodes when the workspace master toggle is OFF. The
+ * block renders inside a sandboxed iframe on the client (harmless, no
+ * same-origin access), so this is NOT an XSS guard — it is the
+ * SERVER-AUTHORITATIVE enforcement of the workspace master toggle for
+ * anonymous shares: an anonymous viewer cannot read the per-workspace
+ * toggle, so when OFF the block is never served, and when ON it is served
+ * and rendered in its sandboxed frame. `htmlEmbedEnabled` is resolved
+ * fail-closed by the callers (missing workspace => OFF => strip).
*
* Both share-content paths — the host page (`updatePublicAttachments`) and
* the share-scoped transclusion lookup (`lookupTransclusionForShare`) —
@@ -490,8 +492,9 @@ export class ShareService {
): Promise {
let pmJson = getProsemirrorContent(content);
- // Kill-switch: when the workspace toggle is OFF, never serve htmlEmbed
- // nodes to public viewers. Strip before tokenizing/serializing.
+ // Master-toggle enforcement: when the workspace toggle is OFF, never serve
+ // htmlEmbed nodes to anonymous public viewers (who cannot read the toggle).
+ // Strip before tokenizing/serializing.
if (!htmlEmbedEnabled) {
pmJson = stripHtmlEmbedNodes(pmJson);
}
diff --git a/apps/server/src/core/workspace/dto/update-workspace.dto.ts b/apps/server/src/core/workspace/dto/update-workspace.dto.ts
index 1beb7ece..404593d6 100644
--- a/apps/server/src/core/workspace/dto/update-workspace.dto.ts
+++ b/apps/server/src/core/workspace/dto/update-workspace.dto.ts
@@ -5,6 +5,8 @@ import {
IsBoolean,
IsInt,
IsOptional,
+ IsString,
+ MaxLength,
Min,
} from 'class-validator';
@@ -53,12 +55,22 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsBoolean()
aiDictation: boolean;
- // Workspace feature toggle for the admin-only HTML embed feature. Persisted at
- // settings.htmlEmbed. ABSENT/false => OFF (default).
+ // Workspace master toggle that enables/disables the HTML embed block type.
+ // Persisted at settings.htmlEmbed. ABSENT/false => OFF (default). The block
+ // itself renders in a sandboxed iframe, so this is a feature switch, not a
+ // security gate.
@IsOptional()
@IsBoolean()
htmlEmbed: boolean;
+ // Admin-only analytics/tracker snippet (raw HTML/JS) injected verbatim into
+ // the of PUBLIC SHARE pages only (same-origin). Persisted at
+ // settings.trackerHead. Admin-authored trusted content.
+ @IsOptional()
+ @IsString()
+ @MaxLength(20000)
+ trackerHead?: string;
+
@IsOptional()
@IsBoolean()
aiPublicShareAssistant: boolean;
diff --git a/apps/server/src/core/workspace/services/workspace-html-embed.spec.ts b/apps/server/src/core/workspace/services/workspace-html-embed.spec.ts
index fda0f5fa..fbab1f6f 100644
--- a/apps/server/src/core/workspace/services/workspace-html-embed.spec.ts
+++ b/apps/server/src/core/workspace/services/workspace-html-embed.spec.ts
@@ -108,4 +108,38 @@ describe('WorkspaceService.update — htmlEmbed toggle persistence (real code)',
expect(logged.changes.before.htmlEmbed).toBe(false);
expect(logged.changes.after.htmlEmbed).toBe(true);
});
+
+ it('persists trackerHead via updateSetting with the trackerHead key', async () => {
+ const { service, updateSetting } = buildService({});
+
+ await service.update('w1', { trackerHead: '' } as any);
+
+ expect(updateSetting).toHaveBeenCalledWith(
+ 'w1',
+ 'trackerHead',
+ '',
+ expect.anything(),
+ );
+ });
+
+ it('does NOT call updateSetting when trackerHead is undefined in the dto', async () => {
+ const { service, updateSetting } = buildService({});
+
+ await service.update('w1', { name: 'New name' } as any);
+
+ expect(updateSetting).not.toHaveBeenCalled();
+ });
+
+ it('audits the trackerHead change (before/after) when the value changes', async () => {
+ const { service, auditService } = buildService({
+ settingsBefore: { trackerHead: '' },
+ });
+
+ await service.update('w1', { trackerHead: '' } as any);
+
+ expect(auditService.log).toHaveBeenCalledTimes(1);
+ const logged = auditService.log.mock.calls[0][0];
+ expect(logged.changes.before.trackerHead).toBe('');
+ expect(logged.changes.after.trackerHead).toBe('');
+ });
});
diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts
index deead2b8..bb564e79 100644
--- a/apps/server/src/core/workspace/services/workspace.service.ts
+++ b/apps/server/src/core/workspace/services/workspace.service.ts
@@ -525,6 +525,22 @@ export class WorkspaceService {
);
}
+ if (typeof updateWorkspaceDto.trackerHead !== 'undefined') {
+ // Admin-only analytics/tracker snippet injected into the of
+ // public share pages (same-origin). Persisted at settings.trackerHead.
+ const prev = (settingsBefore as any)?.trackerHead ?? '';
+ if (prev !== updateWorkspaceDto.trackerHead) {
+ before.trackerHead = prev;
+ after.trackerHead = updateWorkspaceDto.trackerHead;
+ }
+ await this.workspaceRepo.updateSetting(
+ workspaceId,
+ 'trackerHead',
+ updateWorkspaceDto.trackerHead,
+ trx,
+ );
+ }
+
if (typeof updateWorkspaceDto.aiPublicShareAssistant !== 'undefined') {
const prev = settingsBefore?.ai?.publicShareAssistant ?? false;
if (prev !== updateWorkspaceDto.aiPublicShareAssistant) {
@@ -549,6 +565,7 @@ export class WorkspaceService {
delete updateWorkspaceDto.aiChat;
delete updateWorkspaceDto.aiDictation;
delete updateWorkspaceDto.htmlEmbed;
+ delete updateWorkspaceDto.trackerHead;
delete updateWorkspaceDto.aiPublicShareAssistant;
await this.workspaceRepo.updateWorkspace(
diff --git a/apps/server/src/integrations/import/services/file-import-task.service.ts b/apps/server/src/integrations/import/services/file-import-task.service.ts
index c87d4d8f..218c75ca 100644
--- a/apps/server/src/integrations/import/services/file-import-task.service.ts
+++ b/apps/server/src/integrations/import/services/file-import-task.service.ts
@@ -20,14 +20,6 @@ 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 {
- hasHtmlEmbedNode,
- htmlEmbedAllowed,
- isHtmlEmbedFeatureEnabled,
- stripHtmlEmbedNodes,
-} from '../../../common/helpers/prosemirror/html-embed.util';
-import { UserRepo } from '@docmost/db/repos/user/user.repo';
-import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { formatImportHtml } from '../utils/import-formatter';
import {
buildAttachmentCandidates,
@@ -61,8 +53,6 @@ export class FileImportTaskService {
private readonly backlinkRepo: BacklinkRepo,
@InjectKysely() private readonly db: KyselyDB,
private readonly importAttachmentService: ImportAttachmentService,
- private readonly userRepo: UserRepo,
- private readonly workspaceRepo: WorkspaceRepo,
private eventEmitter: EventEmitter2,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@@ -159,29 +149,6 @@ 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,
- );
- // Toggle-AND-admin gate, resolved ONCE for the whole import: htmlEmbed
- // survives only when the workspace feature toggle is ON and the importer is
- // an admin/owner. OFF (default) => stripped for everyone.
- const htmlEmbedEnabled = isHtmlEmbedFeatureEnabled(
- (await this.workspaceRepo.findById(fileTask.workspaceId))?.settings,
- );
- const importerCanAuthorHtmlEmbed = htmlEmbedAllowed(
- htmlEmbedEnabled,
- importingUser?.role,
- );
-
const pagesMap = new Map();
for (const absPath of allFiles) {
@@ -529,21 +496,9 @@ export class FileImportTaskService {
await this.importService.processHTML(html),
);
- let { title, prosemirrorJson } =
+ const { 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,
diff --git a/apps/server/src/integrations/import/services/import-html-embed-identity.spec.ts b/apps/server/src/integrations/import/services/import-html-embed-identity.spec.ts
deleted file mode 100644
index 603765fc..00000000
--- a/apps/server/src/integrations/import/services/import-html-embed-identity.spec.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-import { readFileSync } from 'node:fs';
-import { join } from 'node:path';
-import {
- hasHtmlEmbedNode,
- htmlEmbedAllowed,
- stripHtmlEmbedNodes,
-} from '../../../common/helpers/prosemirror/html-embed.util';
-
-// FAIL-CLOSED IDENTITY for the import write paths.
-//
-// import.service / file-import-task.service cannot be unit-LOADED under the
-// server's jest config (a transitive ESM dep, @sindresorhus/slugify, is not in
-// transformIgnorePatterns). So we cover the two load-bearing properties at the
-// strongest feasible layer:
-//
-// (1) BEHAVIOR — using the REAL html-embed helpers, replay the exact gate
-// predicate each entrypoint runs against the role resolved from
-// userRepo.findById(...): a MISSING user (findById -> undefined) must fail
-// closed (strip), and only 'admin'/'owner' keep the embed.
-//
-// (2) IDENTITY — source-pin which identity governs the gate so a refactor that
-// swaps the lookup to the wrong user (e.g. the queue worker's caller) is
-// caught: zip import resolves the role from `fileTask.creatorId`; single
-// import from the request `userId`. NOT some ambient caller.
-//
-// If a guard is deleted/misplaced or the identity field changes, these break.
-
-const docWithEmbed = () => ({
- type: 'doc',
- content: [
- { type: 'paragraph', content: [{ type: 'text', text: 'imported body' }] },
- { type: 'htmlEmbed', attrs: { source: '' } },
- ],
-});
-
-// The real predicate both import entrypoints apply (see the SECURITY blocks in
-// import.service.ts and file-import-task.service.ts): resolve the importer via
-// userRepo.findById, then `!canAuthorHtmlEmbed(role) && hasHtmlEmbedNode(json)`.
-function applyImportGate(
- json: any,
- featureEnabled: boolean,
- importingUser: { role?: string } | undefined,
-) {
- if (
- !htmlEmbedAllowed(featureEnabled, importingUser?.role) &&
- hasHtmlEmbedNode(json)
- ) {
- return stripHtmlEmbedNodes(json);
- }
- return json;
-}
-
-describe('import gate fail-closed by toggle AND resolved-user role (real helpers)', () => {
- it('toggle ON + missing user (userRepo.findById -> undefined) strips the embed', () => {
- // findById returns undefined when the user/workspace pair does not resolve;
- // undefined?.role is undefined -> htmlEmbedAllowed(true, undefined) === false.
- const result = applyImportGate(docWithEmbed(), true, undefined);
- expect(hasHtmlEmbedNode(result)).toBe(false);
- });
-
- it("toggle ON + resolved role 'member' strips", () => {
- expect(
- hasHtmlEmbedNode(applyImportGate(docWithEmbed(), true, { role: 'member' })),
- ).toBe(false);
- });
-
- it("toggle ON + resolved role 'admin' keeps the embed", () => {
- expect(
- hasHtmlEmbedNode(applyImportGate(docWithEmbed(), true, { role: 'admin' })),
- ).toBe(true);
- });
-
- it("toggle ON + resolved role 'owner' keeps the embed", () => {
- expect(
- hasHtmlEmbedNode(applyImportGate(docWithEmbed(), true, { role: 'owner' })),
- ).toBe(true);
- });
-
- it('toggle OFF strips for every role (admin/owner/member)', () => {
- for (const role of ['admin', 'owner', 'member'] as const) {
- expect(
- hasHtmlEmbedNode(applyImportGate(docWithEmbed(), false, { role })),
- ).toBe(false);
- }
- });
-});
-
-// Source-pin the identity each entrypoint feeds to userRepo.findById. These are
-// the lines that decide WHOSE role governs the gate; pinning them means a
-// refactor that points the lookup at the wrong user trips the test.
-const SRC_DIR = join(__dirname);
-
-describe('import gate identity is pinned to the importer (source contract)', () => {
- it('single import resolves the role from the request userId', () => {
- const src = readFileSync(join(SRC_DIR, 'import.service.ts'), 'utf-8');
- // The role lookup must key on the request `userId`, then gate on the role.
- expect(src).toMatch(
- /this\.userRepo\.findById\(\s*userId\s*,\s*workspaceId\s*\)/,
- );
- expect(src).toMatch(
- /htmlEmbedAllowed\(\s*htmlEmbedEnabled\s*,\s*importingUser\?\.role\s*\)/,
- );
- // And the toggle is resolved from the workspace settings.
- expect(src).toContain('isHtmlEmbedFeatureEnabled(');
- // And the gate uses the real strip helper.
- expect(src).toContain('stripHtmlEmbedNodes(prosemirrorJson)');
- });
-
- it('zip import resolves the role from fileTask.creatorId (NOT the queue caller)', () => {
- const src = readFileSync(
- join(SRC_DIR, 'file-import-task.service.ts'),
- 'utf-8',
- );
- expect(src).toMatch(
- /this\.userRepo\.findById\(\s*fileTask\.creatorId\s*,\s*fileTask\.workspaceId\s*,?\s*\)/,
- );
- expect(src).toMatch(
- /importerCanAuthorHtmlEmbed\s*=\s*htmlEmbedAllowed\(\s*htmlEmbedEnabled\s*,\s*importingUser\?\.role\s*,?\s*\)/,
- );
- expect(src).toContain('isHtmlEmbedFeatureEnabled(');
- expect(src).toContain('stripHtmlEmbedNodes(prosemirrorJson)');
- });
-});
diff --git a/apps/server/src/integrations/import/services/import.service.ts b/apps/server/src/integrations/import/services/import.service.ts
index 574a13ab..19bffe8d 100644
--- a/apps/server/src/integrations/import/services/import.service.ts
+++ b/apps/server/src/integrations/import/services/import.service.ts
@@ -1,13 +1,5 @@
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 {
- hasHtmlEmbedNode,
- htmlEmbedAllowed,
- isHtmlEmbedFeatureEnabled,
- stripHtmlEmbedNodes,
-} from '../../../common/helpers/prosemirror/html-embed.util';
-import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { MultipartFile } from '@fastify/multipart';
import * as path from 'path';
import {
@@ -45,12 +37,10 @@ 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)
private readonly fileTaskQueue: Queue,
- private readonly workspaceRepo: WorkspaceRepo,
) {}
async importPage(
@@ -95,28 +85,7 @@ export class ImportService {
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);
- // Toggle-AND-admin gate: htmlEmbed survives only when the workspace
- // feature toggle is ON and the importer is an admin/owner. OFF (default)
- // => stripped for everyone.
- const htmlEmbedEnabled = isHtmlEmbedFeatureEnabled(
- (await this.workspaceRepo.findById(workspaceId))?.settings,
- );
- if (!htmlEmbedAllowed(htmlEmbedEnabled, importingUser?.role)) {
- this.logger.warn(
- `Stripping htmlEmbed node(s) from import by user ${userId}`,
- );
- prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson);
- }
- }
+ const prosemirrorJson = extracted.prosemirrorJson;
const pageTitle = title || fileName;
diff --git a/packages/editor-ext/src/lib/html-embed/html-embed.ts b/packages/editor-ext/src/lib/html-embed/html-embed.ts
index d3d004a1..93428d2f 100644
--- a/packages/editor-ext/src/lib/html-embed/html-embed.ts
+++ b/packages/editor-ext/src/lib/html-embed/html-embed.ts
@@ -7,8 +7,10 @@ export interface HtmlEmbedOptions {
}
export interface HtmlEmbedAttributes {
- // Raw HTML/CSS/JS string that is injected verbatim into the wiki origin.
+ // Raw HTML/CSS/JS string rendered inside a sandboxed iframe by the NodeView.
source?: string;
+ // Fixed iframe height in pixels. null/absent => auto-resize via postMessage.
+ height?: number | null;
}
declare module "@tiptap/core" {
@@ -90,6 +92,16 @@ export const HtmlEmbed = Node.create({
"data-source": encodeHtmlEmbedSource(attributes.source || ""),
}),
},
+ // Fixed iframe height in px. null/absent => auto-resize on the client.
+ height: {
+ default: null,
+ parseHTML: (el) => {
+ const v = el.getAttribute("data-height");
+ return v ? parseInt(v, 10) : null;
+ },
+ renderHTML: (attrs: HtmlEmbedAttributes) =>
+ attrs.height ? { "data-height": String(attrs.height) } : {},
+ },
};
},