Files
gitmost/docs/arbitrary-html-embed-plan.md
vvzvlad 0cbc9a589f docs(embedding): add docs for arbitrary HTML/CSS/JS embed plan
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.
2026-06-17 23:31:19 +03:00

96 lines
10 KiB
Markdown

# Вставка произвольного HTML/CSS/JS в страницы — анализ и подходы
> Статус: **черновик / обсуждение**. Решение по модели изоляции ещё не принято — см. раздел «Развилка».
> Исходный кейс: нужно вставлять трекер (счётчик аналитики) на вики-страницы.
## 1. Почему «из коробки» произвольный HTML вставить нельзя
Контент страницы в Docmost хранится не как HTML, а как **ProseMirror JSON** (документ TipTap, синхронизируется через Yjs). Любой путь, которым контент попадает в страницу — ручной ввод, вставка из буфера (paste), импорт Markdown/HTML — проходит парсинг строго по схеме редактора:
`apps/server/src/common/helpers/prosemirror/html/generateJSON.ts:45`
```ts
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`:
1. **Новая нода** в `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`.
2. **Регистрация в ДВУХ схемах** (иначе сервер вырежет ноду при сохранении/коллаборации):
- клиент: `apps/client/src/features/editor/extensions/extensions.ts`
- сервер: `tiptapExtensions` в `apps/server/src/collaboration/collaboration.util.ts:58`
3. **React NodeView** на клиенте — то, что реально показывает контент. Здесь и зарыта безопасность (см. развилку).
4. **Markdown-сериализация** (turndown/marked в `packages/editor-ext/src/lib/markdown`) — если нода должна выживать при импорте/экспорте Markdown, иначе там она потеряется.
5. **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. Возможные направления решения (выбрать позже)
1. **Admin-only raw-инъекция** — нода `htmlEmbed` с полным выполнением скрипта в origin вики; вставка только для админов/доверенных ролей. Трекер работает полноценно (куки, уники, URL страницы). Компромисс мощь/риск.
2. **Raw-инъекция без ограничений** — любой автор может вставить произвольный JS. Максимум гибкости, но stored-XSS для всех читателей. ОК только если все редакторы полностью доверенные.
3. **Узкая фича «только трекер», без произвольного JS** — вместо универсальной HTML-ноды поле в настройках для ID счётчика (GA/Метрика), сниппет вставляется в шаблон страницы. Безопасно и решает именно задачу трекинга.
4. **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/`