Adds a draft design document outlining the challenges, security considerations, and possible implementation approaches for inserting arbitrary HTML, CSS, and JavaScript into Docmost pages. Includes analysis of ProseMirror schema limitations, node creation steps, and isolation model options.
10 KiB
Вставка произвольного HTML/CSS/JS в страницы — анализ и подходы
Статус: черновик / обсуждение. Решение по модели изоляции ещё не принято — см. раздел «Развилка». Исходный кейс: нужно вставлять трекер (счётчик аналитики) на вики-страницы.
1. Почему «из коробки» произвольный HTML вставить нельзя
Контент страницы в Docmost хранится не как HTML, а как ProseMirror JSON (документ TipTap, синхронизируется через Yjs). Любой путь, которым контент попадает в страницу — ручной ввод, вставка из буфера (paste), импорт Markdown/HTML — проходит парсинг строго по схеме редактора:
apps/server/src/common/helpers/prosemirror/html/generateJSON.ts:45
PMDOMParser.fromSchema(schema).parse(doc.body, options)
PMDOMParser.fromSchema оставляет только те теги, для которых в схеме есть нода/марк с правилом parseHTML (p, h1–h6, списки, blockquote, code/pre, a, strong/em, таблицы, картинки, callout и т.п.). Всё остальное — <div>, <style>, <script>, инлайн-стили, кастомные теги — молча отбрасывается, выживает максимум текст внутри.
- Ноды «сырой HTML» в схеме нет (
rawHtml/htmlNodeвpackages/editor-ext/src/libотсутствуют). markedсам по себе HTML пропускает насквозь (санитайзер не подключён вpackages/editor-ext/src/lib/markdown/utils/marked.utils.ts), но это неважно — финальным фильтром выступает схема ProseMirror на следующем шаге.- Единственное, что отдалённо похоже на «вставку HTML» — embed-нода (
packages/editor-ext/src/lib/embed.ts), но это<iframe>на URL известных провайдеров с санитизацией ссылки, а не произвольная разметка.
Вывод: чтобы получить произвольный HTML на странице, нужно добавлять в схему редактора отдельную ноду со своим parseHTML/renderHTML и собственным рендерингом.
2. Механика: как добавить такую ноду (одинаково для любого варианта)
По образцу packages/editor-ext/src/lib/excalidraw.ts:
- Новая нода в
packages/editor-ext/src/lib/html-embed/:Node.create({ name: 'htmlEmbed', group: 'block', atom: true, isolating: true }). Атрибутsource(сырой HTML/CSS/JS строкой) сparseHTML/renderHTMLчерезdata--атрибут или base64, чтобы корректно гонялось туда-обратно через HTML↔JSON. Экспорт добавить вpackages/editor-ext/src/index.ts. - Регистрация в ДВУХ схемах (иначе сервер вырежет ноду при сохранении/коллаборации):
- клиент:
apps/client/src/features/editor/extensions/extensions.ts - сервер:
tiptapExtensionsвapps/server/src/collaboration/collaboration.util.ts:58
- клиент:
- React NodeView на клиенте — то, что реально показывает контент. Здесь и зарыта безопасность (см. развилку).
- Markdown-сериализация (turndown/marked в
packages/editor-ext/src/lib/markdown) — если нода должна выживать при импорте/экспорте Markdown, иначе там она потеряется. - UI вставки — slash-команда/кнопка тулбара + модалка с редактором кода.
3. Развилка: модель изоляции (ключевое решение)
«Произвольный JS» в многопользовательской вики — это не фича рендеринга, а модель доверия. От выбора зависит весь NodeView и безопасность всего инстанса.
Вариант A — Sandboxed iframe
Контент кладётся в <iframe sandbox="allow-scripts" srcdoc="...">.
- JS/CSS работают, но изолированы: нет доступа к DOM вики, кукам, токену сессии, localStorage.
- Безопасно, stored-XSS закрыт. Так делают HTML-эмбеды в Notion/Confluence.
- Минусы: скрипт не может управлять самой страницей; авто-высоту приходится решать через
postMessage.
Вариант B — Raw-инъекция в DOM страницы
dangerouslySetInnerHTML + выполнение <script>.
- Полная власть: скрипт выполняется в origin вики, может всё.
- Это stored-XSS by design: скрипт любого автора выполняется в браузере каждого читателя с его сессией → кража токенов, захват аккаунтов.
- Допустимо только на доверенном/одно-пользовательском инстансе.
Вариант C — Raw-инъекция, но admin-only
Полная мощь raw-инъекции, но вставка такой ноды разрешена только админам/доверенным ролям; обычные авторы её добавлять не могут. Компромисс между мощью и риском.
4. Заработает ли трекер в песочнице? — НЕТ (для настоящего трекера)
Без allow-same-origin у iframe opaque origin (null). Из этого следуют ограничения, ломающие именно трекеры:
| Что делает трекер | В sandbox (allow-scripts) |
|---|---|
Загрузить внешний <script src> и выполнить |
✅ работает |
Отправить запрос/пиксель/sendBeacon на свой сервер |
✅ работает (fire-and-forget) |
Поставить куку (document.cookie) |
❌ блокируется/кидает ошибку |
localStorage / sessionStorage |
❌ SecurityError в opaque origin |
| Прочитать URL / referrer / title самой вики-страницы | ❌ видит только about:srcdoc, не родителя |
Достучаться до DOM страницы (window.parent) |
❌ запрещено sandbox'ом |
Итог: GA4 / Яндекс.Метрика / Matomo внутри песочницы либо упадут на попытке поставить _ga/_ym, либо отправят «хит», где страница = about:srcdoc, а уникальный посетитель не сохраняется → данные мусорные. Песочница и «считать саму страницу» — взаимоисключающие вещи by design.
Добавлять allow-same-origin вместе с allow-scripts как «компромисс» нельзя: при одинаковом origin это снимает песочницу полностью (предупреждение MDN) — то есть это та же raw-инъекция окольным путём.
5. Что это значит для выбора
- Цель — аналитика самих вики-страниц (посещения, поведение, уники) → нужен скрипт в origin вики = raw-инъекция. Песочница тут бесполезна в принципе. Риск — stored-XSS, поэтому разумно держать это под admin-only (вариант C).
- Цель — самодостаточный встроенный виджет (калькулятор, демка, виджет без кук и без доступа к родителю) → песочницы (вариант A) хватает.
6. Возможные направления решения (выбрать позже)
- Admin-only raw-инъекция — нода
htmlEmbedс полным выполнением скрипта в origin вики; вставка только для админов/доверенных ролей. Трекер работает полноценно (куки, уники, URL страницы). Компромисс мощь/риск. - Raw-инъекция без ограничений — любой автор может вставить произвольный JS. Максимум гибкости, но stored-XSS для всех читателей. ОК только если все редакторы полностью доверенные.
- Узкая фича «только трекер», без произвольного JS — вместо универсальной HTML-ноды поле в настройках для ID счётчика (GA/Метрика), сниппет вставляется в шаблон страницы. Безопасно и решает именно задачу трекинга.
- Sandboxed iframe (вариант A) — для встраиваемых виджетов; для аналитики самих страниц не годится.
Ссылки на код
- Парсинг по схеме (фильтр HTML):
apps/server/src/common/helpers/prosemirror/html/generateJSON.ts - Серверный список расширений:
apps/server/src/collaboration/collaboration.util.ts:58 - Клиентский список расширений:
apps/client/src/features/editor/extensions/extensions.ts - Реестр нод editor-ext:
packages/editor-ext/src/index.ts - Образец кастомной ноды:
packages/editor-ext/src/lib/excalidraw.ts - Образец iframe-ноды:
packages/editor-ext/src/lib/embed.ts - Markdown ↔ HTML:
packages/editor-ext/src/lib/markdown/