XSS‑уязвимости в Markdown: безопасная очистка rich-text
XSS‑уязвимости в Markdown могут прятаться в комментариях и заметках. Узнайте о безопасной очистке HTML, ограничениях встраиваемого контента и о том, как тестировать реальные вредоносные полезные нагрузки перед запуском.

Почему Markdown и rich-text могут стать уязвимостью\n\nMarkdown и rich-text кажутся безопасными, потому что выглядят как обычный текст. Но многие приложения делают одно и то же «под капотом»: берут ввод пользователя, конвертируют его в HTML и рендерят этот HTML в браузере другого человека.\n\nИменно последняя стадия — где возникают проблемы. Если атакующий сумеет подставить HTML, который браузер воспримет как код, он выполнит JavaScript в сессии другого пользователя. Это и есть XSS (cross-site scripting): скрипт злоумышленника запускается как будто от вашего сайта, с доступом жертвы.\n\nКомментарии, заметки и ответы поддержки — распространённый путь атаки: их легко разместить (часто без рецензирования), они показываются большому числу людей (админам, коллегам, клиентам) и сохраняются для повторного рендера. Один плохой пост может вредить месяцами.\n\nСложность в том, что редакторы Markdown и rich-text часто генерируют HTML, которого вы не ожидали. Простая вставка из Google Docs может принести странные теги и атрибуты. Некоторые настройки Markdown сознательно разрешают raw HTML. Некоторые редакторы добавляют атрибуты, которые вы не планировали поддерживать. Если приложение рендерит такой вывод прямо, вы получаете XSS‑уязвимость в Markdown даже когда интерфейс выглядит безобидно.\n\nРеалистичный пример: основатель добавляет функцию заметок, где коллеги вставляют фрагменты и форматируют текст. Кто‑то вставляет контент с обработчиком события вроде onerror на изображении. Если рендерер сохраняет этот атрибут, каждый раз при открытии заметки админом браузер выполнит пейлоад.\n\nОтображение контента — это функция безопасности, а не просто выбор форматирования.\n\n## Базовые риски XSS в пользовательском контенте\n\nXSS возникает, когда приложение показывает ввод пользователя так, будто это доверённый контент страницы. С Markdown и rich-text риск выше, потому что вы обычно конвертируете ввод в HTML и рендерите его в браузере других людей.\n\nДля комментариев, заметок и профилей самым серьёзным обычно оказывается stored XSS. Кто‑то публикует «комментарий» с хитрым HTML или JavaScript; сервер сохраняет его, и каждый, кто просматривает тему, исполняет код злоумышленника.\n\nReflected XSS — другой распространённый тип: пейлоад сразу же возвращается (обычно из URL или поля поиска). Это тоже важно, но stored XSS причиняет больше проблем с течением времени.\n\nПотенциальные жертвы — не только случайные пользователи. Сотрудники часто подвергаются более высокому риску, потому что видят больше контента: модераторы, поддержка и админы, открывающие панели с пользовательскими постами.\n\nЕсли сработал stored XSS, атакующий может украсть cookies сессии или токены доступа, прочитать приватные данные UI (сообщения, электронные адреса, данные о платёжах), выполнить действия от имени жертвы (публикация, удаление, смена настроек) или подделать UI (фишинг внутри вашего сайта).\n\nУтверждение «только внутренние пользователи имеют доступ» всё равно рискованно. Внутренние аккаунты часто более привилегированы, пароли переиспользуются, и такие аккаунты доверяют другим системам. Один злонамеренный комментарий может быстро перейти от низкоправного пользователя к аккаунту сотрудника.\n\nПример: пользователь публикует заметку, которая выглядит нормально, но содержит обработчик событий в разрешённом HTML. Когда агент поддержки разворачивает заметку, пейлоад тихо отправляет его сессию атакующему. Так XSS в Markdown превращается в реальное перехватывание аккаунта.\n\n## Откуда просачивается небезопасный HTML\n\nМногие команды ожидают проблем только при разрешении raw HTML. Удивление в том, что вы можете получить XSS в Markdown даже когда пользователи вводят «обычный» Markdown.\n\nФункции Markdown компилируются в HTML, который браузер охотно интерпретирует, если вы его не очищаете. Ссылки и изображения — главные кандидаты: невинно выглядящая [text](...) или  становится тегом \\u003ca\\u003e или \\u003cimg\\u003e. Риск обычно скрыт не в самом теге, а в схеме URL и атрибутах, которые в итоге попадают внутрь.\n\nНекоторые парсеры Markdown также по умолчанию разрешают блоки raw HTML. Это значит, что пользователь может вставить \\u003cimg onerror=...\\u003e или \\u003csvg\\u003e прямо в комментарий — это пройдёт через конвертер и выполнится при рендере. Даже если вы думаете «мы экранируем HTML», проверьте настройки парсера и плагины, которые могут вновь его включить.\n\nRich-text редакторы могут быть ещё хуже: они генерируют HTML, который на первый взгляд безобиден. Короткое жирное предложение может включать лишние атрибуты, inline‑стили и странные теги, с которыми ваш санитайзер должен уметь работать. Частая ошибка — разрешить «безопасные» теги, но забыть про опасные атрибуты, такие как обработчики событий (onload, onclick) или атрибуты с URL‑ами, где может скрываться javascript:.\n\nКопирование из Google Docs или Notion часто приносит грязную разметку. Пользователь вставляет форматированный текст, и внезапно у вас появляются вложенные span, inline‑CSS и метаданные, которые вы изначально не планировали. Этот дополнительный HTML повышает шанс обхода или того, что санитайзер нарушит форматирование непредсказуемо.\n\nПри ревью сфокусируйтесь на точках входа, которые чаще всего дают проблемы: raw HTML включён в парсере Markdown; ссылки или изображения с неограниченными схемами; списки «разрешённых атрибутов», которые слишком широки; плагины, добавляющие HTML‑фичи (таблицы, упоминания, встраивания); и пути вставки, которые принимают полноценный HTML вместо plain‑text.\n\n## Выберите безопасную модель контента до выбора санитайзера\n\nБольшинство stored XSS начинается с несоответствия: вы думали, что храните «комментарии», а система фактически хранит мини‑веб‑страницы. Перед выбором библиотеки решите, что именно пользователи могут выражать.\n\nПрактичный подход к Markdown XSS — выбрать одну модель контента и придерживаться её:\n\n- Plain text: самый безопасный и простой вариант. Можно всё ещё поддерживать переносы строк и простое автоссылания.\n- Ограниченный Markdown: подходит для большинства продуктов. Разрешайте форматирование (жирный, курсив, списки, код), но держите его предсказуемым.\n- Полноценный rich-text (похожий на HTML): самый рискованный. Выбирайте только если действительно нужны сложные макеты.\n\nКогда выбор сделан — опишите правила в виде allowlist. Для ограниченного Markdown типичный безопасный набор элементов: p, strong, em, ul, ol, li, code, pre и a. Держите список коротким целенаправленно.\n\nТакже явно укажите, что никогда не разрешено. Очевидные кандидаты: script, iframe, object, embed и style. Но многие «нормальные» теги могут стать опасными в зависимости от окружения, особенно всё, что может загружать удалённый контент или влиять на страницу.\n\nАтрибуты требуют того же подхода. Например, для ссылок разрешайте только href и отвергайте всё, что похоже на обработчик события (onclick, onerror и т.д.).\n\n## Как безопасно санитизировать HTML (не ломая всё)\n\nСамый надёжный подход к Markdown XSS — предполагать, что ввод пользователей и ваши зависимости со временем будут меняться. Парсер Markdown обновится, браузеры изменят поведение, и «безопасные» теги могут получить новые возможности.\n\nПоэтому санитизировать только при сохранении рискованно. Санитизируйте также при рендере, чтобы старый контент оставался безопасным после обновления зависимостей или конфигурации.\n\nПрактическое правило: предпочитайте allowlist. Блок‑листы склонны пропускать пограничные случаи (новые теги, странные атрибуты, особенности браузера). Allowlist отвечает на вопрос: «Что мы разрешаем в комментариях?» — обычно это базовое форматирование, простые ссылки и ничего, что может выполнить код.\n\nПеред санитизацией нормализуйте то, что собираетесь чистить. Атакующие используют трюки вроде закодированных символов и необычных пробелов, чтобы обойти фильтры. Декодируйте сущности, нормализуйте Unicode и распарсьте HTML в реальное дерево (не регулярными выражениями). Затем запустите санитайзер на этой нормализованной структуре, чтобы его не обманули альтернативные написания.\n\nРабочий процесс, который минимизирует ломку форматирования:\n\n- Сконвертируйте Markdown в HTML с помощью надёжной библиотеки.\n- Нормализуйте и декодируйте HTML (сущности, Unicode, пробелы в атрибутах).\n- Просанитизируйте с allowlist (теги + атрибуты + схемы URL).\n- Отрисуйте очищенный результат и отдельно примените безопасную политику Content Security Policy.\n- Логируйте или помечайте контент, который сильно обрезается (обычно признак разведки).\n\nДержите правила санитизации в одном месте и относитесь к ним как к коду. Версионируйте конфигурацию, добавляйте короткий changelog и пишите тесты, которые проверяют, что сохраняется и что удаляется. Пример: если позже вы решите разрешить \\u003cimg\\u003e, обновите allowlist и тесты, а также санитизируйте при рендере, чтобы старые комментарии не стали вновь опасными.\n\n## Заблокируйте ссылки, изображения и стили\n\nСсылки, изображения и стили — это то, где «безопасный» Markdown часто превращается в stored XSS. Даже после очистки тегов нужно относиться ко всем URL и значениям стилей как к недоверенным данным.\n\nНачните со ссылок. Обычная ссылка становится атакой, если вы разрешаете рискованные схемы вроде javascript: или data:. Самое безопасное правило: разрешать https: (и, возможно, http: для внутренних или дев‑сред). Отклоняйте всё остальное. Нормализуйте и декодируйте перед проверкой, потому что атакующие используют смешанный регистр и закодированные символы.\n\nЕсли вы открываете ссылки в новой вкладке через target="_blank", обязательно добавляйте rel="noopener noreferrer". Сделайте это поведением рендерера по умолчанию, а не полагайтесь на авторов.\n\nИзображения — это не «просто картинки». src может указывать на пиксели трекинга, внутренние сетевые ресурсы или странные схемы. Если вы разрешаете изображения, ограничьте схемы в src и рассмотрите проксирование изображений, чтобы браузер не загружал их напрямую с серверов злоумышленников.\n\nСтили — тихая опасность. Даже без запуска скриптов CSS может скрывать предупреждения, перемещать кнопки или сделать поддельный логин‑бокс реалистичным. Для безопасности rich-text предпочитайте маленький allowlist простого форматирования и избегайте разрешения произвольного CSS.\n\nПрактический набор правил:\n\n- Разрешайте только https: (опционально http:) в href и src; блокируйте javascript:, data:, file: и blob:.\n- Если допускаете target="_blank", принудительно ставьте rel="noopener noreferrer".\n- Обрезайте inline style и полностью блокируйте \\u003cstyle\\u003e.\n- Поддержка изображений должна быть минимальной; рассматривайте проксирование и кэширование на сервере.\n- Установите явные лимиты: максимальная длина URL, максимум атрибутов, максимум элементов.\n\nПример: в заметках приложение рендерит Markdown и разрешает изображения. Атакующий публикует «полезную диаграмму», загружающуюся с его сервера, логирующую просмотры, и использует CSS, чтобы скрыть настоящую кнопку «Удалить заметку» под поддельным «Переаутентифицируйтесь». Решение проблем с Markdown XSS — относиться к этим случаям как к базовым рискам продукта, а не к редким исключениям.\n\n## Встраивания: самый быстрый путь случайно разрешить выполнение скрипта\n\nВстраивания кажутся безобидными: «просто видео» или «просто твит». На практике это один из самых быстрых способов превратить пользовательский контент в stored XSS, особенно если Markdown или rich-text позволяют raw HTML. Многие XSS в Markdown начинаются с одной исключённой поддержки iframe.\n\nЕсли вы поддерживаете встраивания, заранее решите, какие провайдеры разрешены и что означает «встраивание» в вашем приложении. «Любой iframe» — не функция, это дыра в безопасности.\n\nБолее безопасный подход: пользователь вставляет обычный URL, ваш сервер проверяет его по allowlist и генерирует итоговый HTML для встраивания. Не принимайте пользовательские теги iframe или произвольные атрибуты вроде srcdoc, onload или allow.\n\nПравила, которые сохранят полезность встраиваний без дачи атакующему поверхности для скриптов:\n\n- Разрешайте только конкретных провайдеров (по hostname и паттерну пути), блокируйте всё остальное.\n- Генерируйте HTML для встраивания на сервере из чистого шаблона, а не из пользовательского HTML.\n- В целом отключайте inline‑iframe в комментариях/заметках, если нет веской причины.\n- Если iframe всё же нужен — задайте фиксированные размеры и строгий sandbox.\n- Обрезайте все обработчики событий и рискованные атрибуты; никогда не позволяйте javascript: URL.\n\nДаже при sandbox помните: встраивания загружают сторонний контент. Обращайтесь с ними как с отдельной границей.\n\n## Тестируйте реальные XSS‑пейлоады до релиза\n\nСанитайзеры часто выглядят хорошо в быстрой демонстрации, но терпят поражение на странном вводе, который создают реальные пользователи. Прежде чем включать комментарии или заметки, прогоните небольшой набор повторяемых тестов, которые попытаются сломать ваш рендерер и правила санитизации.\n\nНачните с тестирования с тремя ролями и тремя видами представления. Используйте обычного пользователя, который может постить контент, и проверьте, что видит модератор и админ. Stored XSS часто срабатывает только когда другой человек загружает страницу, особенно в дашбордах, очередях модерации, превью письма или в панели «последняя активность».\n\nИспользуйте короткий набор пейлоадов, покрывающий распространённые методы обхода (не полагайтесь на один‑два очевидных). Например, пробуйте закодированные символы (HTML‑сущности, смешанный регистр атрибутов), невалидные или незакрытые теги (чтобы запутать парсер), вложенные теги (внешне безопасный тег, опасный вложенный), опасные URL в ссылках (javascript: или data:) и обработчики событий (как onerror) на любых тегах, которые ваш санитайзер допускает.\n\nДержите тесты реалистичными: контент должен рендериться, если он безвреден, но никогда не должен выполнять код. Хорошая проверка: «Отображается ли этот комментарий как текст или безопасная разметка без всплывающих окон, редиректов, сетевых вызовов или неожиданных изменений UI?»\n\nТакже проверьте все места, где показывается тот же контент. Санитизация в редакторе, но не в письме, или в основном комментарии, но не в админ‑таблице — классический путь для stored XSS.\n\n## Распространённые ошибки, приводящие к stored XSS\n\nStored XSS обычно возникает, когда вы считаете пользовательский контент «уже безопасным», потому что он пришёл из продвинутого редактора. WYSIWYG всё ещё может выдавать опасный HTML (или быть обманутым), а парсеры Markdown часто допускают неожиданные пограничные случаи. Поэтому XSS в Markdown появляются даже в продуктах, которые «только поддерживают комментарии».\n\nОдна типичная ловушка — санитизация только в браузере. Очистку на клиенте легко обойти, отправив запрос напрямую в API или повторно проиграв запрос с другого устройства. Если сервер сохраняет неочищенный контент, у вас есть stored XSS, который сработает в любом месте, где содержимое отображается.\n\nДругая ошибка — разрешение raw HTML в Markdown ради удобства (кастомные кнопки, iframe, сложная стилизация). Это тихо превращает Markdown‑фичу в хостинг HTML. Даже при удалении \\u003cscript\\u003e атакующие используют обработчики событий (onerror), хитрые URL или SVG‑пейлоады, в зависимости от того, что вы разрешили.\n\nБольшой источник инцидентов — «вторичные рендереры», о которых забывают. Вы можете просанитизировать основную страницу, но не админ‑вид, не шаблон письма и не экспорт в PDF.\n\nПовторяющиеся ошибки: считать вывод редактора доверенным и сохранять как есть; очищать только на клиенте и записывать сырой контент на сервер; использовать разные санитайзеры или разные allowlist в разных местах; рендерить сохранённый контент в HTML, письма и админ‑инструменты без повторной проверки; логировать или показывать сырой HTML во внутренних дашбордах.\n\nПример: пользователь публикует «безобидный» комментарий с изображением с хитро составленным атрибутом. Публичная страница безопасна, но панель админа использует другой рендерер, и пейлоад срабатывает при открытии очереди модерации.\n\n## Быстрый чек‑лист безопасности для комментариев и заметок\n\nКомментарии и заметки — место, где XSS в Markdown обычно всплывают первым: кажется безобидным и быстро выкатывается. Перед включением для реальных пользователей пройдитесь с безопасным мышлением.\n\nЧек‑лист, который ловит большинство stored XSS проблем:\n\n- Убедитесь, что raw HTML либо полностью отключён в Markdown, либо санитизируется после рендера. Не полагайтесь на «редактор не сгенерирует его».\n- Используйте allowlist для тегов и атрибутов. Блокируйте все обработчики событий вроде onclick и избегайте рискованных атрибутов вроде style, если вы не фильтируете их строго.\n- Валидируйте и нормализуйте URL в href и src. Отклоняйте схемы javascript: и data: (и всё, что вы явно не поддерживаете).\n- Жёстко ограничьте встраивания. Если разрешаете iframe или «вставку ссылки на видео», установите строгие правила или рендерьте как обычные ссылки.\n- Проверьте все места отображения контента, не только главную страницу: админ‑виды, уведомления, мобильные веб‑вью, экспорты (PDF/печать) и внутренние дашборды.\n\nПосле чек‑листа проведите небольшое дымовое тестирование реальными пейлоадами. Цель — не увидеть alert, а убедиться, что ваш вывод везде остаётся инертным.\n\nПопробуйте несколько известных вредоносных вводов (скрипты, обработчики событий, странные URL) и убедитесь, что они рендерятся как текст или удаляются. Проверьте, что в базе хранится безопасная версия, а не только безопасный предпросмотр. Повторите тесты на стороне админки — админы видят больше и имеют более широкие привилегии.\n\n## Пример сценария: простая функция комментариев, превращающаяся в XSS\n\nОснователь выкатывает виджет обратной связи: пользователи могут оставлять Markdown‑комментарии на страницах. Это кажется безопасным, потому что «это просто текст», и предпросмотр выглядит нормально.\n\nЧтобы поддержать rich‑text, приложение конвертирует Markdown в HTML и рендерит его на админ‑дашборде. Кто‑то также добавил «полезные» фичи: автоссылки, поддержка изображений и быстрое встраивание видео.\n\nАтакующий публикует комментарий, который в виджете выглядит нормальным, например отчёт об ошибке со ссылкой. Но Markdown содержит HTML, который конвертер пропускает, или пейлоад прячется в разрешённом атрибуте тега. Атакующий ничего не замечает. Позже админ открывает дашборд, и комментарий выполняет код в браузере админа.\n\nДальше последствия редко тихие. Атакующий может украсть сессию админа, получить доступ к внутренним фидбекам и заметкам, изменить настройки (например, вебхуки или API‑ключи) с правами админа.\n\nБезопасный дизайн остановил бы это заранее. Относитесь к комментариям как к недоверенным данным и ограничьте значение «rich-text»: конвертируйте Markdown в ограниченный набор HTML, санитизируйте с жёстким allowlist, удаляйте или переписывайте рискованные атрибуты (особенно обработчики событий и некоторые схемы URL), по умолчанию отключайте встраивания (или разрешайте только небольшой набор провайдеров с жёсткими правилами) и тестируйте реальные пейлоады именно в том админ‑виде, а не только в публичном виджете.\n\n## Следующие шаги: выкатывайте безопасно и возьмите второе мнение\n\nЕсли вы хотите включать комментарии или заметки без сюрпризов, относитесь к rich‑text как к функции, которая требует простого плана безопасности, а не как к быстрому UI‑добавлению.\n\nНачните с документирования решений, которые можно применять одинаково по всему приложению: выберите модель контента (plain text, Markdown без HTML или санитизированный HTML), определите разрешённые элементы и атрибуты (строго; большинству приложений нужно очень мало), заранее ограничьте встраивания (или отложите их до времени), соберите небольшой набор XSS‑пейлоадов, соответствующих вашим фичам (ссылки, изображения, блоки кода, упоминания) и решите, где выполняется санитизация (серверная — как источник правды).\n\nЗатем добавьте релиз‑гейт. Цель простая: ни один деплой не уходит, пока ваши сохранённые пейлоады не отрисовываются безопасно в реальном UI. Это ловит проблемы, которые не видны в юнит‑тестах, например клиентский Markdown‑плагин, который внезапно включает raw HTML.\n\nРелиз‑гейт может быть лёгким. Прогоните набор пейлоадов через создание, редактирование и предпросмотр. Проверяйте вывод в браузере, а не только в API‑ответах. Убедитесь, что те же правила действуют во всех местах показа контента (лента, письма, админ‑виды). Добавьте по одному регрессионному тесту на каждую найденную уязвимость, чтобы проблема не вернулась.\n\nЕсли ваше приложение сгенерировано или сильно собрано с помощью инструментов вроде Lovable, Bolt, v0, Cursor или Replit, предполагайте, что дефолты могут быть непоследовательны. На одном экране может быть безопасный рендерер, на другом — другая библиотека или режим предпросмотра, который допускает raw HTML.\n\nЕсли хотите быстрое и малотравматичное второе мнение, FixMyMess (fixmymess.ai) специализируется на диагностике и исправлении AI‑сгенерированных кодовых баз, в том числе небезопасных путей рендеринга Markdown и rich-text, и может начать с бесплатного аудита кода, чтобы выявить риски stored‑XSS и смежные проблемы до выката.
Часто задаваемые вопросы
Почему Markdown может быть опасен, если это «просто текст»?
Markdown обычно преобразуют в HTML, и этот HTML затем рендерится в браузере другого пользователя. Если какая‑то часть ввода остаётся в виде исполняемого HTML или опасных атрибутов, это может превратиться в stored XSS — даже когда интерфейс редактора выглядит как «просто текст».
Какой тип XSS чаще всего встречается в комментариях и заметках?
Чаще всего — stored XSS: вредоносный комментарий или заметка сохраняются, а затем выполняются, когда другой человек просматривает запись. Это хуже, чем отражённый XSS, потому что удар может повторяться долгое время и попадать на аккаунты модераторов и админов.
Откуда обычно появляется небезопасный HTML в Markdown или rich-text?
Большая часть проблем появляется из поддержки raw‑HTML, но опасность также приходит через ссылки и изображения, если схемы URL не ограничены. При вставке из rich‑text‑редакторов появляются неожиданные теги и атрибуты, которые ваш санитайзер может не учитывать.
Какую модель контента лучше выбрать для комментариев?
По умолчанию — ограниченный Markdown: разрешите базовое форматирование и ссылки, а всё остальное отклоняйте. Сохраняйте список разрешённых элементов коротким и явным, чтобы не превращать комментарии в мини‑веб‑страницы.
Когда лучше выполнять санитизацию — при сохранении или при рендере?
Санитизируйте при рендере, а не только при сохранении. Санитизация при рендере защищает от изменений парсеров, настроек или поведения браузера, чтобы старый контент не стал опасным после апдейта зависимостей.
Как защитить Markdown‑ссылки от трюков с `javascript:`?
Разрешайте только безопасные схемы вроде https: (в отдельных контролируемых случаях — http:). После декодирования и нормализации отклоняйте javascript:, data:, file: и другие подозрительные схемы — это частая техника для доставки исполнения через обычные ссылки.
Опасны ли изображения в Markdown, или это просто неудобство?
Изображения тоже могут быть опасны. Если вы их поддерживаете — ограничьте схемы в src и подумайте о проксировании изображений через ваш сервер, чтобы браузер не скачивал их напрямую с сервера злоумышленника. Если изображения не критичны, проще их отключить в комментариях.
Как безопасно поддерживать встраиваемый контент (видео, твиты и т.д.)?
Не принимайте произвольный iframe‑HTML от пользователей. Безопаснее позволять вставлять обычный URL, проверять его на сервере по списку разрешённых провайдеров и формировать чистый HTML‑встраиваемый фрагмент на стороне сервера.
Какая распространённая ошибка команд при «санитизации» rich-text?
Один из типичных просчётов — фильтрация только на клиенте. Клиентскую санитизацию легко обойти, отправив запрос напрямую в API. Если сервер сохраняет неочищенный контент, у вас появится stored XSS, который сработает в админ‑видах или письмах.
Как быстро проверить, уязвим ли мой Markdown‑рендерер к XSS?
Прогоните набор реалистичных пейлоадов по всем местам отображения контента: публичная страница, админ/модерация, уведомления и превью. Цель — убедиться, что ввод везде остаётся инертным: без всплывающих окон, перенаправлений, сетевых вызовов или неожиданных изменений UI.