feat(html-embed): per-workspace feature toggle, default OFF

The admin-only raw HTML/JS embed is a deliberate stored-XSS surface, so gate the
whole feature behind a workspace toggle that is OFF by default; it only works
when a workspace admin explicitly enables it.

- settings.htmlEmbed (boolean, default false) + workspace-update field htmlEmbed,
  persisted via WorkspaceRepo.updateSetting with an audit diff. Flipping it is
  admin-only (same Manage Settings CASL as other workspace toggles).
- New gate htmlEmbedAllowed(featureEnabled, role) = featureEnabled && admin/owner.
  All 7 server write paths (create, duplicate, collab onStoreDocument, REST/MCP/AI
  updatePageContent, single + zip import, transclusion unsync) now read the
  workspace's settings.htmlEmbed and strip unless (toggle ON AND admin). OFF
  (default, or a failed/empty workspace lookup) strips htmlEmbed for EVERYONE
  including admins -> existing embeds are cleaned up on next save, none persist.
- Client (defense-in-depth): the /html slash item is hidden unless toggle ON +
  admin; the NodeView executes nothing and shows a 'disabled in this workspace'
  placeholder when OFF; an admin Switch in Workspace Settings -> General with a
  description of the behavior.
- docs/html-embed-admin.md documents the toggle + admin-only + fail-closed
  coedit (a non-admin save strips an admin's embed) + execution semantics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-20 19:28:39 +03:00
parent caac5c7f36
commit 8fcce6a674
23 changed files with 610 additions and 78 deletions

View File

@@ -48,8 +48,18 @@ const docWithEmbed = () => ({
* 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) {
const handler = new CollaborationHandler();
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
@@ -70,7 +80,7 @@ async function gatedContentFor(role: string | null | undefined) {
await handlers.updatePageContent('page-1', {
prosemirrorJson: docWithEmbed(),
operation: 'replace',
user: { id: 'u1', role } as any,
user: { id: 'u1', role, workspaceId: 'ws-1' } as any,
});
return TiptapTransformer.fromYdoc(captureDoc, 'default');
@@ -92,11 +102,19 @@ describe('CollaborationHandler.updatePageContent htmlEmbed admin gate (real code
}
});
it('admin: htmlEmbed preserved', async () => {
expect(hasHtmlEmbedNode(await gatedContentFor('admin'))).toBe(true);
it('toggle ON + admin: htmlEmbed preserved', async () => {
expect(hasHtmlEmbedNode(await gatedContentFor('admin', true))).toBe(true);
});
it('owner: htmlEmbed preserved', async () => {
expect(hasHtmlEmbedNode(await gatedContentFor('owner'))).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);
});
});

View File

@@ -9,10 +9,12 @@ import { setYjsMark, updateYjsMarkAttribute, YjsSelection } from './yjs.util';
import * as Y from 'yjs';
import { User } from '@docmost/db/types/entity.types';
import {
canAuthorHtmlEmbed,
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']
@@ -22,7 +24,7 @@ export type CollabEventHandlers = ReturnType<
export class CollaborationHandler {
private readonly logger = new Logger(CollaborationHandler.name);
constructor() {}
constructor(private readonly workspaceRepo: WorkspaceRepo) {}
getHandlers(hocuspocus: Hocuspocus) {
return {
@@ -98,10 +100,16 @@ export class CollaborationHandler {
// 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)) {
// 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 non-admin update by user ${user?.id} on ${documentName}`,
`Stripping htmlEmbed node(s) from update by user ${user?.id} on ${documentName}`,
);
prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson);
}

View File

@@ -121,7 +121,7 @@ function nodeTypeCounts(json: any): Record<string, number> {
* onStoreDocument to reach the strip + persist branch, and capture the content
* that would be written to the page row.
*/
function buildExtension() {
function buildExtension(featureEnabled = true) {
const captured: { content?: any } = {};
const existingPage = {
@@ -159,6 +159,14 @@ function buildExtension() {
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,
@@ -168,13 +176,18 @@ function buildExtension() {
noopQueue,
collabHistory,
transclusionService,
workspaceRepo as any,
);
return { ext, captured, pageRepo };
}
async function runStore(role: string | null | undefined, doc: Y.Doc) {
const { ext, captured } = buildExtension();
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;
@@ -216,18 +229,33 @@ describe('PersistenceExtension.onStoreDocument htmlEmbed admin gate (real code)'
expect(hasHtmlEmbedNode(reDecoded)).toBe(false);
});
it('admin store: htmlEmbed preserved in persisted content', async () => {
const captured = await runStore('admin', buildYdoc(RICH_DOC));
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('owner store: htmlEmbed preserved', async () => {
const captured = await runStore('owner', buildYdoc(RICH_DOC));
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),

View File

@@ -40,10 +40,12 @@ import {
} from '../constants';
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
import {
canAuthorHtmlEmbed,
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 {
@@ -64,6 +66,7 @@ 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) {
@@ -146,10 +149,16 @@ export class PersistenceExtension implements Extension {
// 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)) {
// 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 non-admin collab store by user ${context?.user?.id} on ${documentName}`,
`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

View File

@@ -1,6 +1,8 @@
import {
canAuthorHtmlEmbed,
hasHtmlEmbedNode,
htmlEmbedAllowed,
isHtmlEmbedFeatureEnabled,
stripHtmlEmbedNodes,
} from './html-embed.util';
import { htmlToJson, jsonToHtml } from '../../../collaboration/collaboration.util';
@@ -105,6 +107,40 @@ describe('canAuthorHtmlEmbed', () => {
});
});
describe('isHtmlEmbedFeatureEnabled', () => {
it('is true only when settings.htmlEmbed === true', () => {
expect(isHtmlEmbedFeatureEnabled({ htmlEmbed: true })).toBe(true);
});
it('defaults to false (absent / false / non-object)', () => {
expect(isHtmlEmbedFeatureEnabled({})).toBe(false);
expect(isHtmlEmbedFeatureEnabled({ htmlEmbed: false })).toBe(false);
expect(isHtmlEmbedFeatureEnabled(null)).toBe(false);
expect(isHtmlEmbedFeatureEnabled(undefined)).toBe(false);
// Truthy-but-not-true values must NOT enable the feature.
expect(isHtmlEmbedFeatureEnabled({ htmlEmbed: 'true' as any })).toBe(false);
});
});
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

View File

@@ -66,3 +66,37 @@ export function hasHtmlEmbedNode(pmJson: unknown): boolean {
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.
*/
export function isHtmlEmbedFeatureEnabled(
settings: unknown | null | undefined,
): boolean {
if (!settings || typeof settings !== 'object') {
return false;
}
return (settings as Record<string, unknown>).htmlEmbed === true;
}

View File

@@ -1,8 +1,8 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
canAuthorHtmlEmbed,
hasHtmlEmbedNode,
htmlEmbedAllowed,
stripHtmlEmbedNodes,
} from '../../../common/helpers/prosemirror/html-embed.util';
@@ -29,49 +29,74 @@ const docWithEmbed = () => ({
],
});
// The real predicate both paths apply (see SECURITY blocks in page.service.ts).
function applyGate(json: any, role: string | null | undefined) {
if (!canAuthorHtmlEmbed(role) && hasHtmlEmbedNode(json)) {
// 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('non-admin (member) strips', () => {
const result = applyGate(docWithEmbed(), 'member');
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('unknown/empty role fails closed (strips)', () => {
it('toggle ON + unknown/empty role fails closed (strips)', () => {
for (const role of [null, undefined, 'viewer'] as const) {
expect(hasHtmlEmbedNode(applyGate(docWithEmbed(), role))).toBe(false);
expect(hasHtmlEmbedNode(applyGate(docWithEmbed(), true, role))).toBe(
false,
);
}
});
it('admin/owner keep', () => {
expect(hasHtmlEmbedNode(applyGate(docWithEmbed(), 'admin'))).toBe(true);
expect(hasHtmlEmbedNode(applyGate(docWithEmbed(), 'owner'))).toBe(true);
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 the caller role param before deriving content/ydoc', () => {
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
// it; the embed must be stripped BEFORE insertPage.
// the combined toggle-AND-admin predicate; the embed must be stripped BEFORE
// insertPage.
expect(SRC).toMatch(
/!canAuthorHtmlEmbed\(\s*callerRole\s*\)\s*&&\s*hasHtmlEmbedNode\(\s*prosemirrorJson\s*\)/,
/!htmlEmbedAllowed\(\s*htmlEmbedEnabled\s*,\s*callerRole\s*\)\s*&&\s*hasHtmlEmbedNode\(\s*prosemirrorJson\s*\)/,
);
expect(SRC).toContain('prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson)');
});
it('duplicatePage() gates on the duplicating user role (authUser.role)', () => {
it('duplicatePage() gates on toggle AND the duplicating user role (authUser.role)', () => {
expect(SRC).toMatch(
/!canAuthorHtmlEmbed\(\s*authUser\.role\s*\)\s*&&\s*hasHtmlEmbedNode\(\s*prosemirrorJson\s*\)/,
/!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(');
});
});

View File

@@ -31,10 +31,12 @@ import {
removeMarkTypeFromDoc,
} from '../../../common/helpers/prosemirror/utils';
import {
canAuthorHtmlEmbed,
hasHtmlEmbedNode,
htmlEmbedAllowed,
isHtmlEmbedFeatureEnabled,
stripHtmlEmbedNodes,
} from '../../../common/helpers/prosemirror/html-embed.util';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import {
htmlToJson,
jsonToNode,
@@ -79,6 +81,7 @@ export class PageService {
private collaborationGateway: CollaborationGateway,
private readonly watcherService: WatcherService,
private readonly transclusionService: TransclusionService,
private readonly workspaceRepo: WorkspaceRepo,
) {}
async findById(
@@ -146,9 +149,18 @@ export class PageService {
// <!--html-embed:BASE64--> 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.
if (!canAuthorHtmlEmbed(callerRole) && hasHtmlEmbedNode(prosemirrorJson)) {
// 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 non-admin page creation by user ${userId} (space ${createPageDto.spaceId})`,
`Stripping htmlEmbed node(s) from page creation by user ${userId} (space ${createPageDto.spaceId})`,
);
prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson);
}
@@ -614,6 +626,12 @@ export class PageService {
const attachmentMap = new Map<string, ICopyPageAttachment>();
// 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);
@@ -724,11 +742,11 @@ export class PageService {
// htmlEmbed node from each duplicated page when the duplicating user is
// not an admin, BEFORE computing textContent/ydoc/insert.
if (
!canAuthorHtmlEmbed(authUser.role) &&
!htmlEmbedAllowed(htmlEmbedEnabled, authUser.role) &&
hasHtmlEmbedNode(prosemirrorJson)
) {
this.logger.warn(
`Stripping htmlEmbed node(s) from non-admin page duplication by user ${authUser.id} (source page ${page.id})`,
`Stripping htmlEmbed node(s) from page duplication by user ${authUser.id} (source page ${page.id})`,
);
prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson);
}

View File

@@ -21,7 +21,7 @@ const sourceContentWithEmbed = () => ({
],
});
function buildService() {
function buildService(featureEnabled = true) {
const pageRepo = {
findById: jest.fn(async (id: string) => ({
id,
@@ -44,6 +44,13 @@ function buildService() {
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)
@@ -55,6 +62,7 @@ function buildService() {
attachmentRepo as any,
storageService as any,
pageAccessService as any,
workspaceRepo as any,
);
return service;
}
@@ -90,8 +98,8 @@ describe('TransclusionService.unsyncReference htmlEmbed admin gate (real code)',
}
});
it('admin: returned content keeps the htmlEmbed', async () => {
const service = buildService();
it('toggle ON + admin: returned content keeps the htmlEmbed', async () => {
const service = buildService(true);
const { content } = await service.unsyncReference(
REF_PAGE,
SRC_PAGE,
@@ -101,8 +109,8 @@ describe('TransclusionService.unsyncReference htmlEmbed admin gate (real code)',
expect(hasHtmlEmbedNode(content)).toBe(true);
});
it('owner: returned content keeps the htmlEmbed', async () => {
const service = buildService();
it('toggle ON + owner: returned content keeps the htmlEmbed', async () => {
const service = buildService(true);
const { content } = await service.unsyncReference(
REF_PAGE,
SRC_PAGE,
@@ -111,4 +119,26 @@ describe('TransclusionService.unsyncReference htmlEmbed admin gate (real code)',
);
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);
});
});

View File

@@ -24,10 +24,12 @@ 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,
htmlEmbedAllowed,
isHtmlEmbedFeatureEnabled,
stripHtmlEmbedNodes,
} from '../../../common/helpers/prosemirror/html-embed.util';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
type ReferencingPageInfo = {
id: string;
@@ -52,6 +54,7 @@ export class TransclusionService {
private readonly attachmentRepo: AttachmentRepo,
private readonly storageService: StorageService,
private readonly pageAccessService: PageAccessService,
private readonly workspaceRepo: WorkspaceRepo,
) {}
async syncPageTransclusions(
@@ -528,9 +531,12 @@ export class TransclusionService {
// 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)) {
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 non-admin transclusion unsync by user ${user.id} (reference page ${referencePageId}, source page ${sourcePageId})`,
`Stripping htmlEmbed node(s) from transclusion unsync by user ${user.id} (reference page ${referencePageId}, source page ${sourcePageId})`,
);
content = stripHtmlEmbedNodes(content);
}

View File

@@ -53,6 +53,12 @@ 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).
@IsOptional()
@IsBoolean()
htmlEmbed: boolean;
@IsOptional()
@IsInt()
@Min(1)

View File

@@ -511,6 +511,20 @@ export class WorkspaceService {
);
}
if (typeof updateWorkspaceDto.htmlEmbed !== 'undefined') {
const prev = settingsBefore?.htmlEmbed ?? false;
if (prev !== updateWorkspaceDto.htmlEmbed) {
before.htmlEmbed = prev;
after.htmlEmbed = updateWorkspaceDto.htmlEmbed;
}
await this.workspaceRepo.updateSetting(
workspaceId,
'htmlEmbed',
updateWorkspaceDto.htmlEmbed,
trx,
);
}
delete updateWorkspaceDto.restrictApiToAdmins;
delete updateWorkspaceDto.aiSearch;
delete updateWorkspaceDto.generativeAi;
@@ -519,6 +533,7 @@ export class WorkspaceService {
delete updateWorkspaceDto.allowMemberTemplates;
delete updateWorkspaceDto.aiChat;
delete updateWorkspaceDto.aiDictation;
delete updateWorkspaceDto.htmlEmbed;
await this.workspaceRepo.updateWorkspace(
updateWorkspaceDto,

View File

@@ -265,6 +265,32 @@ export class WorkspaceRepo {
.executeTakeFirst();
}
/**
* Set a single scalar key at the TOP LEVEL of `settings` (e.g.
* `settings.htmlEmbed`). Mirrors `updateAiSettings`/`updateSharingSettings`
* but without a nested namespace object. `prefKey` comes from a fixed
* allowlist at the call site (inlined via `sql.raw`, never user input); the
* value is inlined via `sql.lit`.
*/
async updateSetting(
workspaceId: string,
prefKey: string,
prefValue: string | boolean,
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
return db
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)})`,
updatedAt: new Date(),
})
.where('id', '=', workspaceId)
.returning(this.baseFields)
.executeTakeFirst();
}
async updateSharingSettings(
workspaceId: string,
prefKey: string,

View File

@@ -21,11 +21,13 @@ 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,
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,
@@ -60,6 +62,7 @@ export class FileImportTaskService {
@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,
) {}
@@ -168,7 +171,16 @@ export class FileImportTaskService {
fileTask.creatorId,
fileTask.workspaceId,
);
const importerCanAuthorHtmlEmbed = canAuthorHtmlEmbed(importingUser?.role);
// 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<string, ImportPageNode>();

View File

@@ -1,8 +1,8 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
canAuthorHtmlEmbed,
hasHtmlEmbedNode,
htmlEmbedAllowed,
stripHtmlEmbedNodes,
} from '../../../common/helpers/prosemirror/html-embed.util';
@@ -36,39 +36,53 @@ const docWithEmbed = () => ({
// 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, importingUser: { role?: string } | undefined) {
if (!canAuthorHtmlEmbed(importingUser?.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 resolved-user role (real helpers)', () => {
it('missing user (userRepo.findById -> undefined) strips the embed', () => {
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 -> canAuthorHtmlEmbed(undefined) === false.
const importingUser = undefined;
const result = applyImportGate(docWithEmbed(), importingUser);
// undefined?.role is undefined -> htmlEmbedAllowed(true, undefined) === false.
const result = applyImportGate(docWithEmbed(), true, undefined);
expect(hasHtmlEmbedNode(result)).toBe(false);
});
it("resolved role 'member' strips", () => {
it("toggle ON + resolved role 'member' strips", () => {
expect(
hasHtmlEmbedNode(applyImportGate(docWithEmbed(), { role: 'member' })),
hasHtmlEmbedNode(applyImportGate(docWithEmbed(), true, { role: 'member' })),
).toBe(false);
});
it("resolved role 'admin' keeps the embed", () => {
it("toggle ON + resolved role 'admin' keeps the embed", () => {
expect(
hasHtmlEmbedNode(applyImportGate(docWithEmbed(), { role: 'admin' })),
hasHtmlEmbedNode(applyImportGate(docWithEmbed(), true, { role: 'admin' })),
).toBe(true);
});
it("resolved role 'owner' keeps the embed", () => {
it("toggle ON + resolved role 'owner' keeps the embed", () => {
expect(
hasHtmlEmbedNode(applyImportGate(docWithEmbed(), { role: 'owner' })),
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
@@ -83,7 +97,11 @@ describe('import gate identity is pinned to the importer (source contract)', ()
expect(src).toMatch(
/this\.userRepo\.findById\(\s*userId\s*,\s*workspaceId\s*\)/,
);
expect(src).toMatch(/canAuthorHtmlEmbed\(\s*importingUser\?\.role\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)');
});
@@ -97,8 +115,9 @@ describe('import gate identity is pinned to the importer (source contract)', ()
/this\.userRepo\.findById\(\s*fileTask\.creatorId\s*,\s*fileTask\.workspaceId\s*,?\s*\)/,
);
expect(src).toMatch(
/importerCanAuthorHtmlEmbed\s*=\s*canAuthorHtmlEmbed\(\s*importingUser\?\.role\s*\)/,
/importerCanAuthorHtmlEmbed\s*=\s*htmlEmbedAllowed\(\s*htmlEmbedEnabled\s*,\s*importingUser\?\.role\s*,?\s*\)/,
);
expect(src).toContain('isHtmlEmbedFeatureEnabled(');
expect(src).toContain('stripHtmlEmbedNodes(prosemirrorJson)');
});
});

View File

@@ -2,10 +2,12 @@ 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,
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 {
@@ -48,6 +50,7 @@ export class ImportService {
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.FILE_TASK_QUEUE)
private readonly fileTaskQueue: Queue,
private readonly workspaceRepo: WorkspaceRepo,
) {}
async importPage(
@@ -101,9 +104,15 @@ export class ImportService {
// imports performed by a non-admin user.
if (prosemirrorJson && hasHtmlEmbedNode(prosemirrorJson)) {
const importingUser = await this.userRepo.findById(userId, workspaceId);
if (!canAuthorHtmlEmbed(importingUser?.role)) {
// 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 non-admin import by user ${userId}`,
`Stripping htmlEmbed node(s) from import by user ${userId}`,
);
prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson);
}