Библиотеки санитизации React для безопасного пользовательского контента
Библиотеки санитизации React помогают показывать пользовательский контент без XSS-дыр. Сравните пакеты для markdown, контроля правил и объема настройки.

Почему пользовательский контент становится опасным в React
Пользовательский контент выглядит безобидно, пока кто-то не подложит в него код. Комментарий, описание профиля, служебная заметка или пост на форуме могут содержать не только текст, если ваше приложение пропустит их как есть.
Атакующему нужно совсем немного. Он может спрятать JavaScript в тегах script, в обработчиках событий вроде onerror или в ссылках, которые используют javascript: вместо обычного URL. Если такой контент попадет на страницу как настоящий HTML, браузер может его выполнить.
React помогает, но только до определенного момента. Когда вы выводите обычную строку внутри JSX, React экранирует ее за вас. Это значит, что \u003cscript\u003e покажется как текст, а не как код.
Риск появляется, когда команда решает показывать сырой HTML. Так часто бывает с dangerouslySetInnerHTML, WYSIWYG-редакторами, контентом из CMS или markdown-пайплайном, который сначала превращает текст в HTML, а потом React его показывает. В этот момент React больше не защищает вас по умолчанию. Ваш код начинает доверять HTML.
Где обычно начинаются проблемы
Markdown кажется безопаснее, потому что он начинается с обычного текста. На практике многие markdown-инструменты сначала превращают его в HTML. Если ваш поток допускает сырой HTML внутри markdown или не очищает ссылки и атрибуты, простая страница документации может стать точкой атаки.
Небольшая ошибка может разойтись очень широко. Допустим, ваш продукт показывает комментарии к блогам, рабочие заметки в панели и markdown-документы в центре помощи. Если один общий компонент рендеринга принимает небезопасный HTML, каждый экран, который его использует, наследует тот же риск.
Именно поэтому команды так внимательно сравнивают библиотеки санитизации React. Вопрос не только в том, удаляет ли библиотека очевидные script-теги. Ей нужно справляться и с хитрыми атрибутами, небезопасными URL, встроенным контентом и тем HTML, который ваше приложение решает разрешить.
Одного плохого пути рендеринга уже достаточно. Если пользователи могут отправлять контент в одном месте, а другие пользователи видят его в другом, проблема очень быстро перестает быть локальной.
Что сравнить перед выбором библиотеки
Первое, что стоит сравнивать, — не демо, не документацию и не скорость установки. Сравните, насколько хорошо библиотека блокирует XSS. Пакет может казаться удобным в первый день и все равно оставить дыры, когда в систему начнет приходить реальный пользовательский контент.
Одни библиотеки санитизации React сосредоточены на очистке сырого HTML. Другие лучше подходят для markdown, особенно если вы сначала превращаете markdown в HTML, а потом уже показываете его. Если ваше приложение поддерживает оба варианта, проверьте весь путь от ввода до вывода. Слабое место часто находится между разбором markdown и финальным рендером.
Полезное сравнение выглядит так:
- Какие опасные теги и атрибуты библиотека удаляет по умолчанию?
- Можно ли разрешить только те теги, которые реально нужны продукту?
- Можно ли ограничить рискованные атрибуты, такие как style, src и настройки iframe?
- Работает ли она в браузере, на сервере или и там и там?
- Исправляют ли мейнтейнеры баги и добавляют ли тесты на безопасность?
Контроль важнее, чем многие ожидают. Простому блогу с комментариями могут понадобиться только абзацы, ссылки, списки и выделение. Документации продукта могут быть нужны еще таблицы, кодовые блоки и заголовки. Если библиотека усложняет управление собственными allowlist, правила начнут расползаться, и люди начнут добавлять небезопасные исключения.
Поддержка вашего стека тоже имеет значение. Если вы рендерите контент в Next.js на сервере и в браузере после гидрации, вам нужно одинаковое поведение в обоих местах. Если в одной части стека sanitization работает иначе, можно получить либо сломанное форматирование, либо, что хуже, контент, который проскочит с одной стороны.
Поддержка и обновления — тихий фильтр, который позже экономит массу проблем. Посмотрите историю релизов, реакцию на баг-репорты и покрытие тестами известных XSS-кейсов. Работа по безопасности редко выглядит эффектно, но устаревшие пакеты быстро стареют. Для безопасного рендеринга HTML в React скучный вариант с ясными правилами и живой поддержкой обычно побеждает.
Чем отличаются основные варианты
Самое большое различие между библиотеками санитизации React — это место в цепочке рендеринга. Одни очищают сырой HTML. Другие лучше работают внутри markdown-пайплайна. Третьи дают точный контроль над тем, какие React-компоненты вообще появляются на странице.
DOMPurify — самый простой выбор, если у вас уже есть HTML и вы хотите надежно очистить его перед рендером. Его поведение по умолчанию довольно строгое, и это хорошо для пользовательского контента. Если в комментарии, WYSIWYG-редакторе или импортированном HTML могут быть опасные теги или атрибуты, DOMPurify обычно дает безопасную отправную точку с минимальной настройкой.
sanitize-html работает более явно. Вы сами задаете, какие теги, атрибуты и схемы URL разрешены, а библиотека удаляет все остальное. Так проще подстроить поведение под правила продукта. Если вы хотите разрешить \u003cb\u003e, \u003ci\u003e, \u003ca\u003e и больше ничего, такой подход выглядит понятным и предсказуемым.
rehype-sanitize лучше подходит, когда контент уже проходит через экосистему unified, например markdown, обработанный через remark и rehype. В такой схеме санитизация на уровне пайплайна чище, чем прикручивать очистку в самом конце. Это также полезно, когда вы сочетаете markdown с ограниченным HTML.
react-markdown немного отличается. Это не в первую очередь санитайзер. Его сила — в контроле. Вы можете решить, как заголовки, ссылки, кодовые блоки и списки будут отображаться как React-компоненты. Это удобно, когда вам важен единый дизайн и более жесткие правила для markdown. Многие команды сочетают его с правилами очистки или плагинами вместо того, чтобы доверять сырому HTML внутри markdown.
Простой способ думать об этих вариантах такой:
- DOMPurify: лучше, когда нужно быстро и безопасно очистить HTML-ввод
- sanitize-html: лучше, когда нужен понятный allowlist
- rehype-sanitize: лучше для markdown и HTML-пайплайнов на unified
- react-markdown: лучше, когда важнее контроль на уровне компонентов, чем поддержка сырого HTML
Важно помнить об одном предупреждении для всех вариантов: сырой dangerouslySetInnerHTML допустим только если до него уже полностью прошел шаг очистки. Если пропустить этот шаг, вы оставите дверь открытой для XSS. Для безопасного рендеринга HTML в React библиотека важна, но порядок действий важен не меньше.
Как на практике проверять защиту от XSS
Санитайзер заслуживает доверия, когда выдерживает грязный ввод, а не когда красиво очищает дружелюбный абзац. Когда вы сравниваете библиотеки санитизации React, прогоняйте через каждую одинаковые враждебные примеры и смотрите на финальный HTML, а не только на то, как это выглядит на экране.
Начните с простых случаев. Хороший пакет должен удалять теги \u003cscript\u003e, но один этот тест почти ничего не доказывает. Многие слабые схемы блокируют script, но все равно пропускают опасные inline-обработчики, такие как onclick, onerror или onload. Полезнее тест \u003cimg src=\"x\" onerror=\"alert(1)\"\u003e, чем обычный script-тег, потому что реальные атаки часто прячутся именно в атрибутах.
Затем проверьте URL. Санитайзеры должны отклонять ссылки javascript:, а к data:-URL нужно относиться осторожно. Некоторые команды разрешают data:image для встроенных изображений, и это может быть нормально, но широкая поддержка data: оставляет простор для злоупотреблений. Посмотрите и на атрибуты ссылок вроде target и rel. Если пользовательский контент может открыть новую вкладку, вывод должен оставаться строгим и предсказуемым.
Небольшой набор тестов помогает:
\u003cscript\u003ealert(1)\u003c/script\u003e\u003ca href=\"javascript:alert(1)\"\u003eclick\u003c/a\u003e\u003cimg src=\"x\" onerror=\"alert(1)\"\u003e- SVG-ввод с обработчиками событий или встроенными ссылками
- Сломанный или плохо сформированный HTML с пропущенными кавычками и незакрытыми тегами
К SVG нужно относиться особенно внимательно. Некоторые библиотеки воспринимают его как обычную разметку, но SVG может содержать скрипты, события и странное поведение пространств имен. С iframe нужна такая же осторожность. Если пакет позволяет оставлять iframe или собственные атрибуты, allowlist должен быть очень жестким. Если в документации туманно описаны правила для SVG, iframe или обработки URL, я бы отнесся к этому настороженно.
Плохо сформированный ввод важен потому, что атакующие редко присылают аккуратный HTML. Они используют сломанную вложенность, разный регистр, лишние кавычки и закодированные символы, чтобы обойти слабые фильтры. Тестируйте не только аккуратный комментарий или markdown-блок, но и грязный ввод.
Еще одна вещь часто подводит команды: разные правила на сервере и в браузере. Если серверный рендер убирает один атрибут, а клиентский его оставляет, вы получите и несоответствие в выводе, и иногда дыру в безопасности. Используйте одну и ту же политику в обоих местах, особенно для документации, комментариев и любого потока sanitization markdown в React. Последовательность скучна, но она закрывает много избежимых ошибок.
Какие инструменты лучше всего подходят для markdown
Если вашему приложению нужен только базовый markdown, держите настройку простой. Заголовки, списки, выделение и ссылки не требуют множества деталей. В таком случае react-markdown часто оказывается самым чистым вариантом, потому что он рендерит markdown как React-элементы, а не отправляет сырой HTML прямо на страницу.
Такой выбор также дает разработчикам больше контроля. Вы можете сопоставлять узлы markdown со своими компонентами, чтобы ссылка, изображение или кодовый блок следовали правилам и стилю вашего приложения. Для комментариев, справки или простых страниц базы знаний этого обычно достаточно.
Сырой HTML меняет уровень риска. Если авторы могут вводить HTML внутри markdown, сначала разберите контент, а потом очистите его после того, как markdown-пайплайн превратит его в структуру, которую можно проверить. Слишком ранняя санитизация — слабая привычка, потому что финальный результат все равно может содержать неожиданные теги или атрибуты.
Если вы уже используете remark и rehype, rehype-sanitize подходит очень хорошо. Он позволяет задать allowlist для тегов, атрибутов и схем URL после обработки markdown. Это сильный вариант для команд, которым нужен тонкий контроль и которые уже работают в markdown AST-пайплайне.
На практике хорошо работает простое разделение:
- Для обычного markdown без сырого HTML используйте
react-markdownи держите правила жесткими. - Для markdown с дополнениями в стиле GitHub, такими как таблицы или task list, добавляйте только нужные плагины.
- Для markdown, который допускает сырой HTML, используйте пайплайн
remarkиrehypeвместе сrehype-sanitize. - Для инструментов, которые отдают одну HTML-строку,
DOMPurifyилиsanitize-htmlмогут ее очистить, но вы потеряете часть контроля на уровне компонентов.
Решите заранее, какие возможности markdown вы разрешаете. Изображения, таблицы, task list и кодовые блоки звучат безобидно, но каждый пункт добавляет правила, которые нужно проверять. Изображения поднимают вопрос об источниках. Таблицы добавляют больше тегов. Кодовые блоки часто требуют классов языков. Task list могут добавить разметку чекбоксов, которую вы захотите ограничить.
Небольшой пример хорошо показывает компромисс. Команда продукта может разрешить короткие заметки о релизах с заголовками, списками и fenced code blocks. Для этого отлично подойдет react-markdown с ограниченным набором плагинов. Публичный форум, где авторы могут вставлять HTML-фрагменты, требует более строгой санитизации, и здесь rehype-sanitize будет более безопасным вариантом.
Как пошагово выбрать библиотеку
Начните с карты контента, а не со списка библиотек. У большинства команд больше точек ввода, чем кажется: комментарии, профили, сообщения в саппорте, заметки админов, статьи помощи и импортированный контент из старых систем.
Относитесь к каждому типу ввода как к отдельному случаю. Обычный текст нужно экранировать, markdown требует контролируемого разбора плюс санитизацию, а сырой HTML — самых строгих правил. Если смешать их в один поток, ошибки появляются очень быстро.
Простой процесс выбора обычно работает хорошо:
- Запишите все места, где пользователи могут отправлять или редактировать контент. Включите публичные формы, внутренние инструменты и любой API, который принимает rich text.
- Пометьте каждое поле как обычный текст, markdown или сырой HTML. Во многих приложениях можно полностью запретить сырой HTML, и это сильно снижает риск.
- Начните с маленького allowlist. Оставьте только те теги и атрибуты, которые реально нужны, например
p,strong,em,a[href],codeи, возможно,pre. - Постройте тесты с реалистичными payload-ами. Проверьте комментарии, ссылки, изображения, code fences, вставки из Word или Google Docs и несколько известных XSS-трюков.
- Оцените стоимость рендеринга до внедрения. Длинные посты, большие ветки комментариев и частые обновления могут превратить безопасную схему в медленную.
Если ваш продукт показывает только простые пользовательские посты, более компактная схема обычно лучше. Например, markdown-парсер плюс санитайзер часто дают больше контроля, чем прием сырого HTML с последующей попыткой все почистить.
Практическое правило
Выбирайте наименее мощный формат ввода, который все еще решает задачу. Если пользователям нужны только абзацы, ссылки и кодовые блоки, markdown обычно защищать проще, чем открытый HTML.
Именно здесь библиотеки санитизации React отличаются по-настоящему важной вещью. Одни легко встраиваются в клиентское приложение, а другие дают более жесткий контроль над разрешенными тегами, правилами атрибутов и обработкой на сервере. Правильный выбор — тот, который команда сможет протестировать, объяснить и поддерживать одинаково во всех местах, где появляется пользовательский контент.
Если два варианта выглядят близко, выбирайте тот, который усложняет случайную отправку опасного ввода. Обычно это экономит больше времени, чем более длинный список возможностей.
Простой пример с комментариями и документацией
SaaS-продукт часто имеет три очень разных типа пользовательского контента: комментарии под тикетами, заметки о релизах от команды и статьи помощи в разделе документации. Им не нужны одинаковые правила для HTML, и если обращаться с ними так, будто нужны, обычно появляются проблемы.
Комментарии — самый простой случай. Большинству приложений стоит хранить их как обычный текст, сохранять переносы строк и, возможно, разрешать совсем небольшой кусок markdown, например выделение или inline code. Если разрешить там полный HTML, люди начнут вставлять странную разметку, которую вы не планировали, и объем очистки быстро вырастет.
Статьи помощи — это другое. Авторам могут понадобиться заголовки, списки, ссылки, цитаты и кодовые блоки. Если санитайзер уберет все это, документацию станет трудно читать. Если же разрешить все, потому что в документации нужно больше структуры, комментарии получат тот же широкий доступ без всякой причины.
Разделяйте правила по типу контента
Одна политика для всех полей звучит аккуратно, но она ухудшает и безопасность, и редактирование. Более удачная схема выглядит так:
- Комментарии: экранировать HTML, разрешать только обычный текст или ограниченный markdown
- Заметки о релизах: разрешать небольшой набор тегов форматирования и ссылок
- Статьи помощи: разрешать более широкий набор, например заголовки, списки, код и таблицы, только если они действительно нужны
Такое разделение сохраняет контроль там, где он нужен. Оно также сильно упрощает безопасный рендеринг HTML в React, потому что каждый рендерер знает, какой именно контент он должен показывать.
Заметки о релизах находятся посередине. Обычно им нужно больше структуры, чем комментариям, но меньше свободы, чем полноценной документации. Многие команды забывают об этом и либо слишком сильно урезают их, либо дают им те же правила, что и статьям.
Вот где библиотеки санитизации React помогают, но библиотека — лишь часть задачи. Более важное решение — это модель контента. Если комментарии, релизы и документы служат разным целям, дайте каждому свой парсер и свои настройки санитизации, а не один глобальный фильтр.
Такой выбор снижает риск и экономит время позже. Вы тратите меньше сил на исправление сломанной разметки, а читатели получают контент, который подходит месту, где они его видят.
Ошибки, которые создают дыры
Даже хорошие библиотеки санитизации React не спасут от ошибок в политике. Большая часть дыр появляется, когда команды доверяют контенту, который только выглядит безобидным, или когда правила со временем расползаются.
Markdown — классический пример. Люди видят обычный текст с несколькими символами и думают, что он безопаснее HTML. Это не так. Многие markdown-парсеры принимают сырой HTML, слишком свободные атрибуты изображений или небезопасные URL, если вы не отключите эти возможности или не очистите результат после разбора. Поле комментария, которое принимает markdown, все равно может вывести небезопасный HTML, если один плагин поменяет правила для ссылок, изображений или встроенного контента.
Открытие пользовательских ссылок с target=\"_blank\" тоже может создать проблемы. Если вы разрешаете этот атрибут, добавляйте строгие значения rel, такие как noopener noreferrer. Иначе новая вкладка сможет взаимодействовать со страницей, которая ее открыла. Звучит незначительно, но это дает недоверенному контенту больше возможностей, чем ожидают многие команды.
Правила разрешений часто становятся слишком широкими. Команда создает один большой allowlist для админов, а потом использует его для модераторов, импортированных документов, партнерского контента и публичных комментариев. Такой короткий путь опасен. Внутренние заметки сотрудников и публичные посты пользователей не должны жить по одним и тем же правилам. Разделяйте их заранее, даже если разница кажется небольшой.
Еще одна ошибка прячется в обновлениях продукта. Команды санитизируют контент в момент сохранения, а потом меняют markdown-парсер, добавляют плагин или меняют то, как React отображает отдельные теги. Старый контент остается в базе, но новый парсер может прочитать его по-новому. Если санитизация была только на этапе сохранения, вы пропустите это изменение. Проверки на этапе рендера ловят больше проблем.
Несколько проверок предотвращают большинство таких дыр:
- Относитесь к markdown как к недоверенному вводу
- Сочетайте
target=\"_blank\"с жесткими правиламиrel - Держите отдельные allowlist для каждого уровня доверия
- Повторно тестируйте сохраненный контент после изменений парсера или рендерера
- Применяйте те же правила к собственным React-компонентам
К собственным компонентам стоит относиться особенно настороженно. Если ваш рендерер превращает пользовательский контент в \u003cVideoEmbed\u003e или \u003cCallout\u003e, эти компоненты могут обойти те же ограничения, которые вы применяете к обычному HTML. Проверяйте каждый prop, ограничивайте URL и считайте, что атакующий будет проверять границы раньше пользователей.
Быстрые проверки перед релизом
Приложение на React обычно получает небезопасный контент из большего числа мест, чем ожидают команды. Комментарии, тикеты в саппорте, markdown-документация, импортированный текст из CMS и старые админские экраны — все это может превратиться в путь для сырого HTML. Перед релизом пройдите по каждому источнику и убедитесь, что он проходит через один слой санитизации с одной понятной политикой. Если один экран использует DOMPurify, а другой тихо доверяет сохраненному HTML, проверка еще не закончена.
Тесты важнее красивого демо. Санитайзер может выглядеть нормально на обычном тексте и все равно провалиться на тех входных данных, которые реально используют атакующие. Дайте своей тестовой системе грязные payload-ы, а не вежливые примеры.
- Попробуйте script-теги, inline-обработчики событий вроде
onerrorи URL сjavascript:. - Проверьте сломанную вложенность, незакрытые теги, вставки HTML из Word и смесь markdown с HTML.
- Проверьте и сохраненный контент, и живой предпросмотр, потому что эти пути часто отличаются.
- Убедитесь, что происходит после повторного редактирования уже очищенного контента в rich text-редакторе.
Правила для клиента и сервера должны совпадать. Если браузер убирает iframe, а сервер его сохраняет, люди увидят одно в предпросмотре и другое после обновления страницы. Такое несоответствие также сильно усложняет поиск ошибок. Выберите один allowlist для тегов, атрибутов и схем URL, а затем применяйте ту же логику везде, где вы очищаете или проверяете контент.
Авторам и редакторам тоже нужны понятные правила. Если ваше приложение принимает абзацы, ссылки, кодовые блоки и списки, скажите это прямо в интерфейсе редактора или в документации. Если оно убирает inline-стили, собственные классы или сырой HTML, скажите и об этом. Люди раздражаются, когда форматирование исчезает без объяснений, а раздраженные пользователи часто начинают искать обходные пути.
Отклоненный контент полезен, но логи сами по себе могут стать риском. Записывайте причину, по которой payload не прошел, ID пользователя или документа и безопасный, экранированный фрагмент, если он нужен для проверки. Не выгружайте сырой вредоносный HTML в дашборды, админские таблицы или письма об ошибках, где кто-то может случайно отобразить его.
Именно здесь библиотеки санитизации React приносят реальную пользу. Лучшее решение скучное: одна политика, повторяемые тесты, одинаковое поведение на сервере и клиенте, и никакого скрытого HTML-обхода в старом компоненте.
Что делать вашей команде дальше
Перестаньте считать весь пользовательский контент одинаковым. Комментарий к продукту, статья поддержки и markdown-документ требуют разных правил. Для каждого типа контента запишите одну политику: какие теги разрешены, какие атрибуты остаются, разрешен ли сырой HTML вообще и кто может публиковать контент.
Храните эту политику в репозитории рядом с кодом, который ее применяет. Когда правила зафиксированы, выбор между библиотеками санитизации React становится намного проще, потому что вы сравниваете инструменты по реальному результату, а не по размытым предпочтениям.
Если вы планируете заменить санитайзер или добавить markdown-плагины, сначала напишите тесты. Возьмите небольшой набор реальных примеров: безобидное форматирование, вставку из Google Docs, сломанный HTML и очевидные атакующие строки. Потом добавьте тесты на результат, чтобы команда точно видела, что изменилось.
Это экономит много боли. Команды часто меняют пакет ради лучшей защиты от XSS для пользовательского контента, а слишком поздно узнают, что обычные посты, таблицы или code blocks больше не отображаются так, как ожидают авторы.
Обычно удачный запуск выглядит так:
- перечислите все места, где пользователи могут отправлять или просматривать rich text
- определите допустимый вывод для каждого места
- добавьте тесты для безопасного, грязного и враждебного ввода
- меняйте по одному инструменту или шагу парсера за раз
- попросите другого инженера проверить правила
Подключайте короткий ревью, когда правила начинают расползаться между frontend-рендерингом, backend-хранилищем, email-шаблонами и админскими инструментами. Именно там всплывают небольшие расхождения. Markdown может быть очищен в приложении, но не в экспортируемой документации, или один экран может разрешать атрибуты, которые другой экран удаляет.
Если вашему стартапу нужна помощь с React-пайплайнами контента или более широкий security review, Oleg на oleg.is может провести практический CTO-level аудит. Лучше всего это работает, когда у команды уже есть черновик политики и несколько тест-кейсов, потому что тогда ревью остается конкретным, а проблемы можно исправить сразу.