javascript

HTML Sanitizer API: конец эпохи DOMPurify и XSS-страданий

  • четверг, 25 июня 2026 г. в 00:00:09
https://habr.com/ru/companies/timeweb/articles/1037990/

Инженеры узнают о межсайтовом скриптинге (Cross-Site Scripting, XSS) тремя способами.

Счастливчики узнают о нем благодаря полезному анализу кода или проактивному правилу проверки кода. Внимательные — во время аудита безопасности, который выявляет уязвимость до того, как она попадет в продакшн.

А есть те, кто узнает о нем через страдания, когда их сайт становится уязвимым. Когда злоумышленник внедряет скрипт, который крадет токены сессий из localStorage, перехватывает файлы cookie или перенаправляет пользователей на фишинговый сайт. Я лично присоединился к клубу «пострадавших» еще в 2005 году, когда встроенная Flash-подпись на форуме, которым я владел, превратилась в кошмар с точки зрения безопасности… но это уже другая история.

В этой статье мы рассмотрим, как браузер, наконец, снимает с нас бремя очистки данных (sanitizing) благодаря новому HTML API Sanitizer.

❯ Проблема innerHTML

Обозначим проблему. На заре интернета innerHTML был волшебной палочкой, позволяющей превращать строки в элементы DOM:

const container = document.getElementById('content');
const userInput = '<img src="x" onerror="alert(\'XSS\')">';
container.innerHTML = userInput;

При выполнении этого кода браузер пытается загрузить несуществующее изображение, терпит неудачу и запускает скрипт в onerror. Поздравляем, вы только что подверглись XSS-атаке.

Приведенный фрагмент кода — классический пример того, как необработанный пользовательский ввод может привести к XSS-уязвимостям. Злоумышленники обычно распространяют подобный вредоносный код следующими способами:

  • пользовательский контент — комментарии, отзывы или любые другие формы пользовательского ввода, отображаемые на странице. Обычно эти данные хранятся в базе данных и обрабатываются позже. Если приложение не обезвреживает пользовательский ввод, это может привести к хранимому (stored) XSS

  • параметры URL — злоумышленники могут создавать URL-адреса с вредоносными данными в параметрах запроса. Если приложение отображает эти параметры на странице без надлежащей проверки, это может привести к отраженному (reflected) XSS. Например, страница поиска, которая принимает параметр запроса и отображает его на странице без проверки, может быть использована для атаки

Исторически это решалось с помощью DOMPurify. Это де-факто библиотека для очистки HTML в JavaScript. Она работает путем анализа входной строки, удаления любых опасных элементов или атрибутов и возврата безопасной версии HTML:

import DOMPurify from 'dompurify';

const container = document.getElementById('content');
const userInput = '<img src="x" onerror="alert(\'XSS\')">';
const sanitizedInput = DOMPurify.sanitize(userInput);

container.innerHTML = sanitizedInput;

Или, если вы используете React, можно сделать что-то подобное с помощью dangerouslySetInnerHTML для отображения очищенного контента:

import DOMPurify from 'dompurify';

function Comment({ content }) {
  const sanitizedContent = DOMPurify.sanitize(content);
  return <div dangerouslySetInnerHTML={{ __html: sanitizedContent }} />;
}

DOMPurify — это фантастический инструмент, отлично справляющийся с очисткой данных, но не без недостатков. Он поставляется в минифицированном виде размером около 23,3 КБ (примерно 8,71 КБ в сжатом виде), требует поддержки и, по сути, повторяет парсинг HTML, выполняемый браузером.

Последний пункт имеет решающее значение. Библиотеки типа DOMPurify всегда были довольно хрупким решением. API парсинга, доступные веб-браузеру, не всегда точно соответствуют тому, как браузер фактически отображает строку в виде HTML в «реальном» DOM. Хуже того, этим библиотекам приходится постоянно следить за развитием браузеров, поскольку то, что когда-то было безопасным, может превратиться в бомбу замедленного действия в момент появления новой функции платформы. Это заставляет разработчиков библиотек играть в догонялки с каждым релизом браузера, и как только библиотека достигает размера и богатства DOMPurify, эта гонка превращается в работу на полный рабочий день. Браузер, с другой стороны, точно знает, когда и как он будет выполнять код. Внедрение обезвреживания в браузер означает, что он по определению остается синхронизированным с парсером.

❯ Новый HTML Sanitizer API

Веб-платформа теперь включает новые API, которые делают парсинг и обезвреживание HTML более безопасным. Спецификация определяет безопасные способы вставки HTML в DOM как альтернативу старому innerHTML.

API предоставляет 6 методов, разделенных на 2 группы:

  • безопасные методы: Element.setHTML(), ShadowRoot.setHTML(), Document.parseHTML(). Эти методы всегда удаляют опасный с точки зрения XSS контент, независимо от переданных настроек

  • небезопасные методы: Element.setHTMLUnsafe(), ShadowRoot.setHTMLUnsafe(), Document.parseHTMLUnsafe(). Эти методы допускают любой контент, определенный настройками

Рассмотрим эти методы подробнее.

> setHTML: безопасный способ вставки HTML

Метод setHTML — дополнение DOM API, позволяющее разработчикам устанавливать контент HTML способом, защищенным от уязвимостей XSS. При использовании setHTML браузер автоматически обезвреживает ввод, удаляя любые потенциально опасные элементы или атрибуты. Он безопасен по умолчанию. Мы можем настраивать его, но любой опасный контент все равно будет удален перед рендерингом. Он, например, всегда будет удалять элементы <script> и атрибуты on*. Он эффективно перезаписывает наши слишком «мягкие» настройки.

Простейшее использование не нуждается в настройке. Просто вызываем setHTML со строкой:

const maliciousInput = '<img src="x" onerror="alert(\'XSS\')">';
document.getElementById('content').setHTML(maliciousInput);

// Результат: <img src="x"> — атрибут onerror удален, что предотвращает атаку XSS.

Вот и все. Скрипт в атрибуте onerror удален, поскольку браузер выполнил логику обезвреживания в процессе парсинга, используя встроенные дефолтные безопасные настройки. Если вам интересно, какие элементы и атрибуты допускает дефолтный конфиг, на MDN есть полный список.

❯ Настраиваемое обезвреживание

Спецификация позволяет определять объект конфигурации с указанием разрешенных или запрещенных элементов и атрибутов. Определение правильной конфигурации может быть немного сложным, поскольку можно случайно указать элемент как в списке разрешенных, так и в списке запрещенных, или указать атрибут несколько раз. API строг на этот счет: при передаче невалидной конфигурации выбрасывается исключение TypeError. Это обеспечивает осведомленность разработчиков о любых противоречиях или избыточности в их конфигурации.

Рассмотрим пример конфигурации с разрешенным списком:

const config = {
  elements: ["em", "strong", "b", "i", "ul", "li"],
  attributes: ["id"],
  replaceWithChildrenElements: ["span", "div"],
};
const customSanitizer = new Sanitizer(config);

Эта конфигурация разрешает только определенный набор элементов и атрибутов. Все, что не входит в список, удаляется. Настройка replaceWithChildrenElements позволяет определять элементы, которые должны заменяться потомками вместо полного удаления. Поэтому, например, <div> во вводе будет удален, а его содержимое останется.

Конфигурация с запрещенным списком:

const config = {
  removeElements: ["span", "script"],
  removeAttributes: ["lang", "id", "class", "style"],
  comments: false,
};
const customSanitizer = new Sanitizer(config);

Эта конфигурация определяет элементы и атрибуты, которые должны быть удалены из ввода. Настройка comments определяет, должны ли сохраняться комментарии HTML. В данном случае они удаляются.

Нельзя определять elements и removeElements в одной конфигурации, поскольку они служат разным целям. Тоже самое касается attributes и removeAttributes. В противном случае, выбрасывается исключение TypeError. Можно комбинировать elements и removeAttributes или attributes и removeElements.

Обратите внимание, что в обоих примерах мы не заботились об опасных атрибутах, таких как инлайновые обработчики событий (on*). Это объясняется тем, что setHTML безопасен по умолчанию. Он удаляет все, что может привести к уязвимостям XSS, независимо от конфигурации.

setHTMLUnsafe: запасной вариант

setHTMLUnsafe — небезопасный аналог setHTML:

  • в случае setHTML наш конфиг — это дополнительные к дефолтным ограничения. Небезопасный контент всегда удаляется, даже если мы его явно разрешаем

  • в случае setHTMLUnsafe наш конфиг — единственный источник ограничений. Если мы разрешим onclick, он останется. Отсутствие конфига означает отсутствие обезвреживания

Существует 2 основных причины использования этого метода:

  1. Декларативные теневые корневые элементы (shadow roots). setHTML удаляет их по умолчанию, поэтому для их сохранения в настоящее время нужно использовать setHTMLUnsafe.

  2. Намеренное разрешение определенных «небезопасных» атрибутов. Иногда нам необходимы инлайновые обработчики событий или что-то подобное.

Разница в коде:

const input = "<img src=x onclick=alert('onclick') onerror=alert('onerror')>";

// setHTMLUnsafe с кастомным конфигом: onclick разрешен, onerror удаляется
// (поскольку мы не указали его в списке, а не потому что API обеспечивает безопасность по умолчанию).
const lessSafeConfig = new Sanitizer({
  attributes: ["onclick"],
});
document.getElementById('output').setHTMLUnsafe(input, { sanitizer: lessSafeConfig });

onerror удаляется, потому что не указан в разрешенном списке, а не потому что setHTMLUnsafe обеспечивает безопасность по умолчанию. Если мы напишем attributes: ["onclick", "onerror"], оба атрибута останутся. В случае setHTML это неважно. Оба атрибута будут удалены.

parseHTML и parseHTMLUnsafe: обезвреживание без вставки

Иногда мы не хотим рендерить HTML сразу. Возможно, мы сначала хотим его распарсить, исследовать, преобразовать и только потом решить, что с ним делать. Для этого предназначены Document.parseHTML() и Document.parseHTMLUnsafe().

const untrustedHTML = '<p>Hello <script>alert("xss")</script>world</p>';

// Возвращает обезвреженный Document, который можно исследовать, обходить и из которого можно извлекать элементы
const doc = Document.parseHTML(untrustedHTML);
console.log(doc.body.innerHTML); // <p>Hello world</p>

parseHTML похож на setHTML в том, что уязвимости XSS всегда удаляются. parseHTMLUnsafe не выполняет обезвреживание по умолчанию.

Это может быть полезным для таких вещей, как однократное создание обезвреженного DocumentFragment и его многократное повторное использование, или выполнение проверок обезвреженного вывода перед решением о его рендеринге.

❯ Случаи реального использования

Давайте проясним одну важную вещь: даже с новым API серверного обезвреживания никто не отменял. Клиентское обезвреживание предназначено для улучшения пользовательского опыта и немедленной безопасности. Злоумышленник может обойти клиентский код и обратиться к API напрямую. Аналогией может служить валидация ввода пользователя на клиенте для лучшего UX и его валидация на сервере для бизнес-логики и безопасности.

В 704-м эпизоде ​​подкаста ShopTalkShow Дэйв Руперт и Крис Койер пригласили Фредерика Брауна из Mozilla для обсуждения HTML Sanitizer API. Они говорили об использовании этого API для оптимистичного обновления UI — популярного шаблона во фронтенд-разработке.

В случае комментариев, например, когда пользователь нажимает кнопку «Опубликовать», мы обычно полагаемся на бэкэнд, который обрабатывает комментарий и возвращает его клиенту, который его рендерит. Это занимает время и создает неоптимальный UX. Доверять необработанному пользовательскому вводу и отображать его немедленно может быть рискованно, но с новым API мы можем безопасно отображать комментарий сразу, не дожидаясь его обработки бэкэндом. В результате получается гораздо более удобный UI без ущерба для безопасности.

import React, { useState, useRef, useEffect } from 'react';

type Comment = { id: number; content: string };

const sanitizerConfig = { elements: ["b", "i", "em", "ul", "li"] };
const sanitizer = new Sanitizer(sanitizerConfig);

function CommentItem({ html }: { html: string }) {
  const ref = useRef<HTMLLIElement>(null);

  useEffect(() => {
    if (!ref.current) return;
    if ('setHTML' in ref.current) {
      ref.current.setHTML(html, { sanitizer });
    } else {
      // Резерв для браузеров, которые пока не поддерживают этот API: используем DOMPurify
      // или рендерим индикатор загрузки до получения обезвреженного ответа от сервера.
      ref.current.textContent = html;
    }
  }, [html]);

  return <li ref={ref} />;
}

const CommentSection = () => {
  const [comments, setComments] = useState<Comment[]>([]);

  const handleSubmit = (userInput: string) => {
    // 1. Оптимистичное обновление - рендерим сразу, безопасно
    const newComment = { id: Date.now(), content: userInput };
    setComments((prev) => [...prev, newComment]);

    // 2. Отправляем на сервер (который по-прежнему является источником истины)
    postComment(userInput);
  };

  return (
    <ul>
      {comments.map((comment) => (
        <CommentItem key={comment.id} html={comment.content} />
      ))}
    </ul>
  );
};

Отметим несколько вещей:

  • Sanitizer создается один раз на уровне модуля, а не внутри компонента. Его создание при каждом рендеринге является пустой тратой ресурсов

  • вызов setHTML в useEffect обернут в предохранитель, чтобы компонент не сломался в браузерах, которые пока не поддерживают этот API

  • мы работаем с <li> через ref, вместо того, чтобы смешивать dangerouslySetInnerHTML с механизмом согласования (diffing) React. Такое сочетание обычно вызывает проблемы с гидратацией

Вот где setHTML блистает во всей красе. Мы не можем передавать необезвреженную строку в dangerouslySetInnerHTML без риска создания уязвимости XSS, а API предоставляет нам эргономичную и безопасную альтернативу.

Еще несколько случаев, когда этот API может быть очень полезным:

  • Редакторы WYSIWYG. Пользователи обычно пишут контент в текстовых редакторах и вставляют его, перетаскивая огромный, некорректный HTML и встроенные стили. Отслеживание события paste и его обработка с помощью setHTML позволяют очистить контент перед рендерингом

  • Предварительный просмотр Markdown в реальном времени. Даже если поле ввода никогда не покидает браузер, все равно необходимо проверять сгенерированный HTML перед его отображением

  • Внешние каналы. RSS-ленты, синдицированный контент, встроенные фрагменты кода и все, что поступает извне, должно быть очищено перед тем, как попасть в DOM

❯ Заключение

HTML Sanitizer API — это значительный шаг вперед в повышении безопасности и эффективности веб-разработки. Он переводит безопасность из разряда «задачи библиотеки» в разряд «примитивов платформы». Благодаря этому мы получаем более высокую производительность, меньший размер пакетов и более безопасное поведение по умолчанию.

На момент написания статьи (май 2026 г.) поддержка этого API браузерами находится на ранней стадии. Firefox 148 поддерживает его с февраля 2026 года. Chrome поддерживает его в Canary за флагом, а Safari еще не начал работу над реализацией, хотя команда заявила о своем положительном отношении к этой фиче. Этот API еще не является базовым, а это значит, что для его использования в продакшне сегодня по-прежнему требуется резервный вариант (DOMPurify).

К счастью, веб всегда позволял нам использовать функции до их полной стандартизации или поддержки. Используйте этот API как прогрессивное улучшение уже сейчас и следите за поддержкой. День, когда он получит полную поддержку, станет днем, когда многие пакеты станут немного меньше, а многие приложения — немного безопаснее.


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале