Очистка ненадежного HTML-кода

Веб-функции: Sanitizer API

Безопасный показ HTML-кода, созданного пользователями, – это распространенная проблема безопасности. Встроенный Sanitizer API позволяет удалять опасный контент (например, теги <script> или атрибуты on*) до того, как он попадет в DOM.

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

1. Безопасная вставка HTML с помощью setHTML()

Основной способ использования Sanitizer API – метод setHTML() для любого Element. Этот метод выполняет синтаксический анализ, очистку и вставку за один шаг, обеспечивая очистку контента специально для контекста, в который он будет вставлен.

const untrustedHTML = `
  <p>Hello!</p>
  <script>alert('XSS')</script>
  <img src="x" onerror="alert('XSS')">
  <div onclick="doBadThing()">Click me</div>
`;

const container = document.getElementById('output');

// Sanitize and insert in one call
// The default sanitizer removes scripts and event handlers, plus other
// elements and attributes outside its built-in allowlist (such as style)
container.setHTML(untrustedHTML);

2. Использование пользовательских конфигураций дезинфектора

Вы можете настроить правила очистки, создав экземпляр Sanitizer. Это позволяет точно определить, какие элементы и атрибуты разрешены для вашего конкретного случая использования.

При использовании Element.setHTML() и Document.parseHTML() настройки Sanitizer по умолчанию удаляют все элементы и атрибуты, небезопасные с точки зрения межсайтового скриптинга. Вы не можете создать более разрешающее правило Sanitizer, которое разрешает эти элементы, но можете дополнительно ограничить то, что разрешено.

// Define a restrictive configuration that only allows the specified elements and attributes.
const config = {
  elements: ["p", "b", "i", "strong", "em"],
  attributes: ["class"],
  replaceWithChildrenElements: ['div']
};

const mySanitizer = new Sanitizer(config);

// Apply custom sanitization
container.setHTML(untrustedHTML, { sanitizer: mySanitizer });

Элементы, не входящие в elements, удаляются вместе с контентом. Перечислите элементы-оболочки, текст которых вы хотите сохранить (например, div выше), в replaceWithChildrenElements. Это позволит удалить элемент, но сохранить его дочерние элементы.

Если вам нужен менее строгий Sanitizer, который разрешает некоторые небезопасные элементы или атрибуты, вы можете использовать собственный Sanitizer с Element.setHTMLUnsafe() и Document.parseHTMLUnsafe().

3. Анализ пользовательского ввода с помощью Document.parseHTML()

Если вам нужно проанализировать ненадежный HTML-код, не вставляя его сразу в DOM, используйте Document.parseHTML(). Этот метод возвращает объект Document, очищенный в соответствии с правилами по умолчанию или специальными правилами. Вы можете проверить или преобразовать очищенные узлы (например, чтобы создать предварительный просмотр или извлечь текст) перед их вставкой.

Переместите полученные узлы на страницу, а не сериализуйте их в строку и повторно анализируйте с помощью innerHTML, что может привести к повторному возникновению мутационного межсайтового скриптинга (mXSS).

const rawHTML = '<p>Hello <script>console.log("bad")</script></p>';

const doc = Document.parseHTML(rawHTML);
// doc.body now contains the sanitized nodes: <p>Hello </p>
preview.replaceChildren(...doc.body.childNodes);

Резервные варианты и поддержка Sanitizer API в браузерах

Если нативный Sanitizer API недоступен в целевых браузерах, загружайте библиотеку, например DOMPurify, только когда нативная поддержка отсутствует, чтобы не отправлять ее в браузеры, которым она не нужна. Используйте проверки in для обнаружения функций, чтобы сама проверка работала в старых браузерах.

let domPurifyPromise;

function loadDOMPurify() {
  // Code-split with your bundler so DOMPurify is only downloaded when needed
  domPurifyPromise ??= import('dompurify').then((mod) => mod.default ?? mod);
  return domPurifyPromise;
}

export async function safeSetHTML(el, html) {
  const elements = ['p', 'b', 'i', 'strong', 'em'];
  const attributes = ['class'];

  if ('setHTML' in Element.prototype) {
    // setHTML() accepts a plain configuration object as the sanitizer
    el.setHTML(html, { sanitizer: { elements, attributes } });
  } else {
    const DOMPurify = await loadDOMPurify();
    el.innerHTML = DOMPurify.sanitize(html, {
      ALLOWED_TAGS: [...elements, '#text'],
      ALLOWED_ATTR: attributes,
      // Match the native Sanitizer: strip aria-*/data-* and disallowed element content
      ALLOW_ARIA_ATTR: false,
      ALLOW_DATA_ATTR: false,
      KEEP_CONTENT: false
    });
  }
}

По умолчанию DOMPurify сохраняет атрибуты aria-* и data-* (даже если указан ALLOWED_ATTR), а также текстовое содержимое удаляемых элементов, тогда как встроенный Sanitizer удаляет атрибуты aria-* и data-* и удаляет запрещенные элементы вместе с их содержимым. Чтобы DOMPurify работал так же, как нативная функция, задайте для ALLOW_ARIA_ATTR: false, ALLOW_DATA_ATTR: false и KEEP_CONTENT: false значение true, а для #text – false. В DOMPurify нет встроенного параметра, эквивалентного replaceWithChildrenElements, поэтому не используйте его в конфигурациях, для которых требуется соответствующий резервный вариант.

Если на странице используются доверенные типы (require-trusted-types-for 'script'), то при назначении строки для innerHTML будет выдаваться ошибка. Для setHTML() правила Trusted Types не нужны, но они требуются для резервного объявления. Передайте RETURN_TRUSTED_TYPE: true в DOMPurify.sanitize() и разрешите название правила dompurify в директиве CSP trusted-types.