javascript

Редактор писал «Сохранено» и ничего не сохранял: TipTap поверх чужого HTML

  • среда, 28 октября 2026 г. в 00:00:05
https://habr.com/ru/articles/1100138/

Я делаю сервис, который пишет статьи и сам публикует их на сайты клиентов. Как устроена публикация в четыре CMS, я уже рассказывал здесь. Сегодня про соседний кусок: редактор в личном кабинете, где человек правит статью перед публикацией.

Как минимум два месяца этот редактор показывал «Сохранено» и не сохранял ни символа. Сама ошибка глупая, на неё хватит одного абзаца. Хуже, что очевидное исправление уничтожило бы статьи. По дороге к правильному нашлись ещё три ловушки. Санитайзер nh3 тратит почти минуту на 320 КБ разметки. Одно правило HTML5 превращает 99 КБ входа в 56 МБ выхода. А SELECT … FOR UPDATE в SQLAlchemy берёт блокировку и при этом отдаёт старые данные.

Стек: FastAPI, SQLAlchemy 2.0, PostgreSQL, на фронте Next.js и TipTap 3. Все замеры ниже я повторил на чистом окружении, скрипты короткие, их легко прогнать у себя.

Куда пропадал текст

Страница отправляла правку примерно так:

await articlesApi.update(siteId, articleId, {
  content_html: editor.getHTML(),
  title, h1_tag, slug, meta_description,
});

А схема запроса на сервере выглядела так:

class ArticleUpdate(BaseModel):
    title: Optional[str] = None
    slug: Optional[str] = None
    meta_description: Optional[str] = None
    h1_tag: Optional[str] = None
    content_markdown: Optional[str] = None

Поля content_html в ней нет. В Pydantic v2 лишние поля по умолчанию не вызывают ошибку (extra="ignore"), их молча отбрасывают. Сервер сохранял заголовок и ЧПУ, отвечал 200, интерфейс рисовал «Сохранено». Правка жила в редакторе до перезагрузки страницы, после неё возвращался старый текст.

Дешевле всего такое ловит model_config = ConfigDict(extra="forbid") во входных схемах: запрос сразу получает 422, и ошибка не проживёт и дня. Но добавить поле в схему было только половиной дела. Вторая половина: понять, что именно мы начнём сохранять.

Почему нельзя просто принять поле

Статья, которую собирает сервис, состоит не только из абзацев и заголовков. В ней бывают таблицы, картинки, видео в iframe (RuTube, VK Видео), блок вопросов и ответов на <details>, квизы и чек-листы на чистом CSS со своим <style>, врезка «кратко о главном», микроразметка JSON-LD в <script> и служебные HTML-комментарии вроде <!--more-->.

TipTap со StarterKit знает абзацы, заголовки, списки, цитаты, код и ссылки. Картинок и таблиц в StarterKit нет. Входной HTML ProseMirror разбирает по схеме документа и всё, что в неё не вписывается, выбрасывает или сплющивает в текст. Первое же сохранение превратило бы таблицу в столбик абзацев, FAQ в обычный текст, а картинки, видео и микроразметку удалило бы целиком.

Защита от этого во фронтенде, кстати, была. Перед загрузкой в редактор особые блоки вырезались регулярками и заменялись метками, а при сохранении возвращались на место:

const patterns = [
  /<script\s+type="application\/ld\+json"[\s\S]*?<\/script>/gi,
  /<table[\s\S]*?<\/table>/gi,
  /<div[^>]*class="[^"]*interactive-block[^"]*"[^>]*>[\s\S]*?<\/div>/gi,
  /<iframe[\s\S]*?<\/iframe>/gi,
  /<img[^>]*src="https?:\/\/[^"]*IMAGE_HOST[^"]*"[^>]*\/?>/gi,
];
// каждый найденный блок заменялся на <p>⚡ INTERACTIVE_0 ⚡</p>

Выходит, статьи спасло то, что сервер ничего не сохранял. Прими он поле, защита сработала бы так:

  • ленивое [\s\S]*?<\/div> останавливается на первом же </div>, а внутри квиза их десяток. В метку уходит обрывок, остальное достаётся редактору и пропадает;

  • картинки защищались только с одного хоста, все прочие редактор выбросил бы;

  • FAQ на <details> в список не попал вообще;

  • метка была обычным текстом в абзаце. Её можно стереть, поправить или скопировать, и тогда блок либо теряется, либо встаёт не туда;

  • блоки хранились в браузере, и сервер принял бы любой HTML из запроса, включая произвольный <script>.

Ещё два сюрприза нашлись в самом TipTap. У расширения Link по умолчанию target="_blank" и rel="noopener noreferrer nofollow", так что после сохранения nofollow встал бы на всю внутреннюю перелинковку статьи. А заголовки теряли атрибут id, на который ведут ссылки оглавления.

Плашки вместо блоков

Правильная схема получилась такой: редактор правит только то, что понимает, а всё остальное остаётся на сервере.

Перед открытием редактора сервер режет статью на куски верхнего уровня. Абзац, заголовок, список или цитата, внутри которых только строчные теги, уходят в редактор как есть. Всё прочее заменяется пустой плашкой:

<div data-block="3f9a1c0b7e24d5a8" data-kind="table" data-info="Сравнение тарифов"></div>

Идентификатор плашки состоит из первых 16 символов SHA-256 от исходного куска. Режет разметку обычный html.parser.HTMLParser из стандартной библиотеки. Полный построитель дерева HTML5 здесь не нужен, достаточно знать, где кончается каждый элемент верхнего уровня. Неявные закрытия учитывать всё же приходится: модель любит не закрывать <p> перед <figure> и </li> перед следующим <li>, а браузер в таких местах закрывает тег сам.

Главное свойство разбора: склейка кусков даёт исходную строку байт в байт. Благодаря этому вставка нового раздела не трогает остальную статью.

В TipTap плашка стала атомарным узлом, который можно перетащить или удалить, но нельзя редактировать:

Узел плашки для TipTap
export const LockedBlock = Node.create({
  name: "lockedBlock",
  atom: true,
  selectable: true,
  draggable: true,
  addAttributes() {
    return {
      id: {
        default: null,
        parseHTML: (el) => el.getAttribute("data-block"),
        renderHTML: (attrs) => ({ "data-block": attrs.id }),
      },
      // вид и подпись нужны только на экране, на сервер уходит один id
      kind: { default: "html", parseHTML: (el) => el.getAttribute("data-kind"), renderHTML: () => ({}) },
      info: { default: "", parseHTML: (el) => el.getAttribute("data-info"), renderHTML: () => ({}) },
    };
  },
  parseHTML: () => [{ tag: "div[data-block]" }],
  renderHTML: ({ HTMLAttributes }) => ["div", HTMLAttributes],
});

// плашки живут только на верхнем уровне: таблица внутри пункта списка сломала бы список
export const ArticleDoc = Node.create({ name: "doc", topNode: true, content: "(block | lockedBlock)+" });

Подпись на плашке выводится через textContent, без разметки: туда попадает текст из статьи, а он в конечном счёте пришёл из ответа модели.

При сохранении сервер делает обратное:

def merge_editor_html(editor_html: str, stored_html: str) -> str:
    check_markup(editor_html)                    # линейная проверка, о ней ниже
    blocks = {p.id: p.html for p in split_parts(stored_html) if p.kind == "block"}
    clean = sanitize(editor_html)                # nh3: строгий список тегов плюс наш div с data-block
    out, pos, used = [], 0, Counter()
    for start, end, bid in find_placeholders(clean):
        out.append(unwrap_divs(clean[pos:start]))
        pos = end
        if bid not in blocks:
            raise MarkupError("unknown_block")   # ответ 409, редактор перечитает статью
        used[bid] += 1
        if used[bid] <= 3:
            out.append(blocks[bid])              # кусок из базы, байт в байт
    out.append(unwrap_divs(clean[pos:]))
    return "".join(out)

Подменить блок через редактор нельзя. Содержимое сервер берёт только из того, что уже лежит в базе, а от клиента принимает один идентификатор.

Неизвестный идентификатор даёт 409, а не тихий пропуск. Подделку и устаревшую вкладку и так отсекает отпечаток тела (о нём ниже), поэтому чужой id появляется, когда после выкладки поменялись правила разбора. В этом случае редактор должен перечитать статью, а не сохранить её без половины картинок.

Одну плашку можно вставить не больше трёх раз. Картинку иногда честно хотят повторить, а тысяча копий таблицы похожа уже на попытку раздуть тело статьи.

Голый <div> без идентификатора разворачивается в своё содержимое. TipTap такой не строит, а если его пропустить, при следующем открытии он сам станет новой плашкой, которую уже не поправишь.

Эквивалентного HTML тут мало, нужен именно исходный, байт в байт. На точные адреса картинок завязана их перезаливка в CMS: коннекторы Битрикса и InSales находят в теле адрес картинки строкой и подменяют его своим. Стоит санитайзеру переставить атрибуты или превратить & в &amp;, и замена промахнётся, а картинка на сайте останется ссылкой на чужой сервер.

Оборванный хвост

Модель иногда упирается в лимит токенов посреди тега. В конце статьи остаётся <a href="https://exa без кавычки и угловой скобки или <script> без закрывающего тега. Всё после такого места браузер прочтёт как продолжение атрибута или тело скрипта, и абзац, дописанный человеком после обрыва, окажется внутри атрибута.

Поэтому всё, начиная с первого куска, который не заканчивается в обычном тексте, и до конца статьи становится одной плашкой-хвостом. Сюда относятся оборванный тег, незакрытая кавычка или комментарий, <script> или <style> без конца. Хвост всегда остаётся последним, где бы его ни поставили в редакторе, а новые разделы вставляются перед ним.

HTML-комментарии тоже стали отдельными плашками. <!--more--> делит запись WordPress на анонс и полный текст, другие служебные комментарии отмечают места для картинок и видео при повторной генерации. TipTap выбросил бы их все.

Санитайзер, который можно положить одним запросом

Текст из редактора чистит nh3, биндинги к Rust-библиотеке ammonia, которая разбирает HTML через html5ever. Он быстрый и строгий: в правке не остаётся ни скриптов, ни обработчиков событий, ни iframe.

Но санитайзер прежде всего парсер. А правка приходит из браузера, и в запросе может оказаться что угодно, не обязательно то, что построил TipTap. Вот сколько nh3 тратит на разметку из одних открывающих <div>:

Вложенность

Размер входа

nh3.clean

1 000

5 КБ

0,003 с

4 000

20 КБ

0,035 с

16 000

80 КБ

0,74 с

32 000

160 КБ

6,7 с

64 000

320 КБ

54,5 с

Для сравнения: 512 КБ плоских <p>x</p> подряд чистятся за 0,1 с. Время зависит не от размера, а от глубины. На малых глубинах удвоение вложенности умножает его примерно вчетверо, на больших уже в 8–9 раз. Мегабайт таких тегов с таким темпом занял бы ядро на часы, и проверять это я не стал.

Сервер при этом не замирает. Пока nh3 работает в отдельном потоке, основной поток Python продолжает считать, так что GIL отпускается. Но ядро занято целиком, а на сервере с двумя ядрами два таких запроса забирают всё.

99 КБ на входе, 56 МБ на выходе

Вторая находка неприятнее, потому что разметка выглядит плоской:

html = "".join(f'<p><b class="c{i}">x</p>' for i in range(3))
print(nh3.clean(html, tags={"p", "b"}, attributes={"b": {"class"}}))
<p><b class="c0">x</b></p>
<p><b class="c0"><b class="c1">x</b></b></p>
<p><b class="c0"><b class="c1"><b class="c2">x</b></b></b></p>

nh3 тут ни при чём, так устроена спецификация HTML5. Незакрытый <b> остаётся в списке активных элементов форматирования, и каждый следующий абзац заново открывает внутри себя все элементы из этого списка. Ограничитель в спецификации есть, он так и называется: Noah’s Ark clause. Одинаковых элементов в списке держится не больше трёх. Только одинаковыми считаются элементы с тем же именем и теми же атрибутами, и разный class у каждого <b> обходит ограничитель.

Если повторить такой абзац 4 000 раз, получится 99 КБ на входе, 15 секунд работы и 56 МБ на выходе. Дерево растёт квадратично, хотя во входной строке нет ни одной глубокой вложенности. Браузер разобрал бы эту строку точно так же.

Линейная проверка до санитайзера

Выручило то, что TipTap отдаёт HTML, сериализуя дерево DOM. В настоящей правке каждый тег закрыт, и закрыт в правильном порядке. Всё, что выглядит иначе, — подделанный запрос, и пускать его до nh3 незачем:

Код проверки check_markup
VOID = frozenset("area base br col embed hr img input link meta param source track wbr".split())
TAG_RE = re.compile(r"""<(/?)([a-zA-Z][^\s/<>]*+)((?:[^<>"']|"[^"]*"|'[^']*')*+)>""")

def check_markup(src: str) -> None:
    if len(src) > 500_000:
        raise MarkupError("too_large")
    stack, pos = [], 0
    while (lt := src.find("<", pos)) >= 0:
        m = TAG_RE.match(src, lt)
        if m is None:
            nxt = src[lt + 1: lt + 2]
            if nxt in ("!", "?", "/") or (nxt.isascii() and nxt.isalpha()):
                raise MarkupError("bad_markup")   # похоже на тег, но не разобралось
            pos = lt + 1                          # одиночный «<» в тексте
            continue
        name = m.group(2).lower()
        if m.group(1):                            # закрывающий тег
            if not stack or stack[-1] != name:
                raise MarkupError("bad_markup")
            stack.pop()
        elif name not in VOID:
            stack.append(name)
            if len(stack) > 80:
                raise MarkupError("too_deep")
        pos = m.end()
    if stack:
        raise MarkupError("bad_markup")

На тех же 320 КБ вложенных <div> проверка отказывает за 0,1 мс вместо 54 секунд, на бомбе с <b> ещё быстрее. Порог 80 взят с запасом: глубже 20 уровней редактор не строит.

В самой регулярке две детали. Внутри тега запрещён < вне кавычек, а квантификаторы притяжательные (*+, они есть в re с Python 3.11) и не откатываются. Без запрета на < строка вида <a<a<a… заставила бы каждую попытку совпадения бежать до конца строки в поисках >, и проверка, которая защищает от квадратичного времени, сама стала бы квадратичной.

Сверху стоит ограничение частоты: 40 сохранений и 3 млн символов в минуту на пользователя. Честная правка на 500 тысяч символов стоит около 2,5 секунды процессора (самая большая статья у нас занимает 123 тысячи вместе с блоками), и поток таких запросов тоже заметен.

FOR UPDATE, который ничего не защитил

Вторая вкладка браузера или автосохранение поверх только что применённой правки не должны молча затирать друг друга. Схема обычная. Вместе с текстом редактор получает отпечаток тела, SHA-256 от content_html, и при сохранении присылает его обратно. Сервер под блокировкой строки сравнивает отпечаток с текущим, и если тот не совпал, отвечает 409, а редактор показывает предупреждение о конфликте.

В первой версии не хватало одного вызова:

article = get_article_or_404(article_id, site_id, db)   # проверка прав, статья уже в сессии
...
article = db.query(Article).filter(Article.id == article.id).with_for_update().one()
if body.base_hash != content_hash(article.content_html):
    raise HTTPException(409, {"code": "content_changed"})

Блокировка берётся, запрос ждёт, пока соседняя транзакция завершится, база отдаёт свежую строку. А в article.content_html остаётся старое значение.

Виновата identity map. Объект с этим первичным ключом уже лежит в сессии: его загрузила проверка прав. Получив из базы строку с тем же ключом, SQLAlchemy возвращает существующий объект и не перезаписывает его атрибуты. Отпечатки сравниваются по устаревшим данным, и вторая вкладка перетирает первую без всякого 409.

Воспроизводится за десяток строк, даже на SQLite:

a_sess = Session(engine)
a = a_sess.get(Article, 1)                                  # body = "v1"

with Session(engine) as b:                                  # «вторая вкладка»
    b.get(Article, 1).body = "v2"
    b.commit()

stale = a_sess.execute(select(Article).where(Article.id == 1).with_for_update()).scalar_one()
print(stale.body, stale is a)                               # v1 True

fresh = a_sess.execute(
    select(Article).where(Article.id == 1).with_for_update()
    .execution_options(populate_existing=True)
).scalar_one()
print(fresh.body)                                           # v2

Лечится это populate_existing: с ним SQLAlchemy перезаписывает атрибуты объектов данными из результата. В документации к with_for_update() прямо сказано, что вместе с ним «обычно стоит использовать» populate_existing(). Но интуитивно ждёшь, что FOR UPDATE и так означает свежие данные. И одна оговорка: несохранённые изменения объекта populate_existing тоже стирает, поэтому блокировку надо брать до того, как вы начали что-то менять.

Фоновые задачи тоже пишут в статью

Отпечаток и блокировка защищают правки друг от друга, но в ту же строку пишет ещё и фоновая задача. Публикация на сайт идёт через Celery: задача читает статью, перезаливает картинки в CMS и записывает тело обратно уже с новыми адресами. Первые секунды статья остаётся в статусе «готова», и редактор разрешает сохранить. Если человек успевал нажать «Сохранить» в это окно, задача потом записывала поверх тело, прочитанное до его правки.

Блокировкой это не лечится: задача должна писать только своё. Задача публикации больше не записывает тело статьи. Новые адреса картинок она сохраняет, только если тело в базе не изменилось с той минуты, когда она его прочитала.

Похожая история случилась в начале сентября с заголовками. Страница сохраняла форму целиком, и если автосохранение срабатывало раньше, чем поля заполнялись загруженными данными, на сервер уходили пустые строки. model_dump(exclude_none=True) их не отсекал: пришёл не None, а "". Так 117 статей остались без заголовка и ЧПУ. Теперь сервер просто не принимает пустую строку для этих полей, стирать заголовок целиком всё равно никому не нужно.

Превью, которое исполняло скрипты статьи

Последняя дыра нашлась на соседней вкладке «Предпросмотр». Чтобы там работали интерактивные блоки, страница после dangerouslySetInnerHTML пересоздавала каждый <script> статьи:

container.querySelectorAll("script").forEach((old) => {
  const s = document.createElement("script");
  Array.from(old.attributes).forEach((a) => s.setAttribute(a.name, a.value));
  s.textContent = old.textContent;
  old.parentNode?.replaceChild(s, old);
});

Скрипт, вставленный через innerHTML, браузер не выполняет, а созданный через createElement выполняет. Этот приём часто советуют, чтобы «оживить» вставленный HTML, и он превращает предпросмотр в запуск произвольного кода в домене кабинета.

HTML статьи пишет не тот, кто её смотрит. Часть приходит из ответа модели, часть собирается из поисковых запросов и подписей к картинкам, и экранировалось это не везде. Открывает статью не только автор, но и поддержка. Токены кабинета хранятся в localStorage, так что любой <script>, доживший до превью, получил бы чужую сессию.

Теперь превью рендерится в <iframe sandbox> без allow-scripts, а HTML перед этим проходит через DOMPurify. Интерактивные блоки сделаны на чистом CSS и работают без JavaScript, видео в превью заменяется ссылкой. Правило, которое я себе записал: HTML, который ты не писал сам, не рендерится в своём origin, даже для просмотра.

Что я бы сделал сразу, если бы начинал заново

  1. Ставил бы extra="forbid" во всех входных схемах. Молча отброшенное поле — худший вид ошибки, потому что всё выглядит рабочим.

  2. Не отдавал бы rich-text редактору то, чего он не понимает. Блоки хранит сервер, клиент получает на них непрозрачные ссылки по хешу и может их только двигать.

  3. Помнил бы, что санитайзер — это парсер. Перед ним нужна линейная проверка размера, глубины и порядка тегов, ведь HTML5 умеет раздувать дерево даже из плоской на вид разметки.

  4. Не верил бы, что with_for_update() в ORM означает свежие данные. Если объект уже в сессии, без populate_existing блокировка защищает только саму себя.

  5. Разрешал бы фоновой задаче писать только то, за что она отвечает, и только если исходные данные не поменялись.

  6. Показывал бы чужой HTML только в песочнице без скриптов.

Интересно, как это решают у вас, особенно второй пункт: редактор должен править статью, половина содержимого которой ему недоступна, и при этом ничего в ней не сломать.

Замеры: nh3 0.3.7, SQLAlchemy 2.0.36, Python 3.11, AMD Ryzen 5 4600H, один поток.