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

Convert the htmlEmbed node from same-origin raw-HTML execution to a sandboxed
iframe (sandbox="allow-scripts allow-popups allow-forms", no allow-same-origin,
srcdoc) with postMessage auto-resize (validated by event.source) and an optional
manual height attr. The block now runs in an opaque origin and cannot reach the
viewer's cookies/session/API, so it is safe for any member.

Because the block is now harmless, remove the entire admin/role gating apparatus:
drop htmlEmbedAllowed/canAuthorHtmlEmbed/stripDisallowedHtmlEmbedNodes/
collectHtmlEmbedSources and every role-based strip on the write paths (collab
REST/MCP + socket, page create/duplicate, import x2, transclusion unsync), along
with the now-unused WorkspaceRepo/UserRepo injections and the PageService.create
callerRole param. Keep one strip: prepareContentForShare still removes htmlEmbed
on the anonymous public-share read path when the workspace master toggle is OFF.

The workspace settings.htmlEmbed toggle is now a plain feature switch (gates the
slash-menu and share rendering); when ON the block is available to all members.

Add settings.trackerHead: an admin-only raw HTML/JS analytics snippet injected
verbatim into the <head> of public share pages only (ShareSeoController), for
trackers that genuinely need same-origin. Admin-gated via the existing CASL
Manage/Settings ability; never injected into the authenticated app shell.

Closes security-review findings #1, #2, #4, #5, #10 (and #3 as a security issue).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude_code
2026-06-21 02:48:41 +03:00
parent b98c9d51c6
commit 81823fce1e
35 changed files with 482 additions and 1387 deletions
@@ -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 <head> 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;
@@ -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: '<script>ga()</script>' } as any);
expect(updateSetting).toHaveBeenCalledWith(
'w1',
'trackerHead',
'<script>ga()</script>',
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: '<script>m()</script>' } 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('<script>m()</script>');
});
});
@@ -525,6 +525,22 @@ export class WorkspaceService {
);
}
if (typeof updateWorkspaceDto.trackerHead !== 'undefined') {
// Admin-only analytics/tracker snippet injected into the <head> 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(