For the complete documentation index, see llms.txt. This page is also available as Markdown.

Очистка HTML (санитайзер)

Как безопасно выводить HTML, который написал пользователь

Если пользователь может писать текст с разметкой — сообщение на форуме, статью, описание файла, — этот текст нельзя выводить как есть. Вместе с оформлением в него легко попадает то, что выполнится в браузере другого посетителя: <script>, обработчики событий вроде onerror, ссылки со схемой javascript:. Убирает всё это санитайзер.

Текст, который выводится на страницу как контент — сообщение форума, комментарий, статья, — чистится не отдельным вызовом санитайзера, а конвейером контента: он и очищает разметку, и рисует медиа со смайлами. Эта страница — про остальные случаи: значение чистят, но не выводят как контент.

Главное правило

Чистим на выводе, а не при сохранении. В базе текст лежит ровно таким, каким его написал автор — это позволяет менять правила очистки без переписывания уже сохранённых данных и не ломает редактирование.

Как пользоваться

Внедрите Johncms\Security\HtmlSanitizerInterface через конструктор и передайте текст в sanitize():

<?php

declare(strict_types=1);

namespace Johncms\Modules\MyModule\Application\Services;

use Johncms\Security\HtmlSanitizerInterface;
use Twig\Markup;

final readonly class ArticleTextFormatter
{
    public function __construct(
        private HtmlSanitizerInterface $sanitizer,
    ) {
    }

    public function format(string $text): Markup
    {
        return new Markup($this->sanitizer->sanitize($text), 'UTF-8');
    }
}

Результат заворачивается в Twig\Markup — это принятый в JohnCMS способ сказать шаблону «здесь готовая разметка». Шаблон печатает такое значение обычным способом, без |raw:

Twig экранирует всё, что печатает через {{ }} (см. Создание собственного шаблона). Исключение — значения, которые являются разметкой по договорённости, то есть Markup. Поэтому |raw для очищенного текста не нужен: если он понадобился, значит сервис вернул строку вместо Markup.

Если текста нет, sanitize() вернёт пустую строку. Обычно в таком случае удобнее вернуть null, а не пустой Markup: объект всегда истинный, и проверка {% if x %} в шаблоне на пустом Markup не сработает.

Политики

Вызывающий код говорит, какого рода у него контент, а не как его чистить. Вид контента описывает Enum Johncms\Security\HtmlPolicy:

Политика
Для чего
Что разрешает

RichContent (по умолчанию)

сообщения форума, комментарии, статьи — всё, что написано в редакторе

блочные элементы, изображения, таблицы, встроенное медиа, разрешённые CSS-классы; голые ссылки превращаются в кликабельные

Inline

короткий текст внутри подписи или предложения

только инлайновое оформление и ссылки — ничто не разорвёт строку, в которой стоит

InlineWithParagraphs

короткий самостоятельный текст

то же плюс p и span

Политика передаётся вторым аргументом:

Пример из модуля согласий: заголовок согласия стоит рядом с чекбоксом в форме, поэтому ссылку в нём разрешить нужно, а абзац — нет, иначе вёрстка формы разъедется. Текст баннера cookie — отдельный блок, там абзацы уместны.

Обычный текст

toPlainText() — тот же контент, но без разметки: теги сняты, HTML-сущности раскодированы, идущие подряд пробелы схлопнуты. Это то, что нужно для превью в списке, заголовка страницы, хлебных крошек и meta description.

Разрешённые CSS-классы

Для политики RichContent действует белый список классов из config/autoload/htmlpurifier.global.php:

Класс, которого нет в списке, вырезается из атрибута class, остальные остаются. Так пользователь не сможет разметить свой текст служебными классами темы и сломать вёрстку страницы. Правка списка применяется сразу, чистить кэш не нужно.

Когда санитайзер не нужен

Он предназначен для разметки. Есть случаи, когда его применять не следует:

  • Адрес (URL). Ссылку нельзя обезопасить очисткой разметки — её защищает проверка схемы. Разрешите http и https (либо локальный путь) и соберите ссылку сами. Пример — UserMutators::getWebsiteAttribute().

  • Значение, которое выводится как текст. Имя, город, номер телефона печатаются через {{ }}, и Twig экранирует их сам. Прогонять их через санитайзер незачем.

  • Текст, который пишет только разработчик. Строки перевода и литералы в шаблонах не приходят извне.

Своя политика для модуля

Если ни одна из трёх политик не подходит вашему контенту, объявите свою. Править ядро для этого не нужно — иначе правку стёрло бы следующим обновлением CMS.

Модуль регистрирует сервис, реализующий HtmlPolicyProviderInterface:

Отдельного тега в config/services.php модуля прописывать не нужно — достаточно, чтобы сервис попал в контейнер обычным способом (load() с autoconfigure()). Дальше политика запрашивается по имени:

Поля определения

Поле
Значение

name

Уникальное имя политики. Соглашение — <модуль>.<контент>, чтобы имена модулей не сталкивались

elements

Разрешённые элементы и их атрибуты: ['a' => ['href'], 'b' => []]. Всё, что не перечислено, вырезается

allowedClasses

Имена классов, которые выживут в атрибуте class. null — пропускать любые, [] (по умолчанию) — ни одного

linkify

Превращать ли голые ссылки в тексте в кликабельные

linkSchemes

Схемы, разрешённые в ссылках. По умолчанию http, https, mailto

frameTargets

Значения, допустимые в атрибуте target. По умолчанию только _blank

Определение — это белый список: пустое определение снимает всю разметку, а расширить разрешённое сверх перечисленного оно не может.

Часть вещей объявить нельзя вовсе: элементы, которые несут поведение или подгружают ресурс (script, iframe, object, form, style, base, meta, svg и подобные), атрибуты, начинающиеся на on (onclick, onerror), и схемы javascript:, data:, vbscript:, file:. Попытка объявить такое приводит к InvalidArgumentException сразу при создании определения — ошибка видна автору модуля, а не автору отчёта об уязвимости.

Чего делать не нужно

  • Создавать свой санитайзер. Тогда правила очистки расползаются по модулям, и их нельзя ни сравнить, ни проверить одним ревью.

  • Рассчитывать на подстановку политики по умолчанию. Запрос политики с именем, которое никто не объявил, бросает UnknownHtmlPolicyException. Это сделано намеренно: молчаливый откат к другой политике почистил бы текст по чужим правилам.

  • Обращаться к встроенным политикам по имени. 'RichContent' как строка не сработает — к трём встроенным политикам ведёт только enum HtmlPolicy. Так модуль не сможет перехватить имя, по которому чистится контент всего сайта.

Кэш

Правила очистки компилируются один раз и кэшируются в data/cache/htmlpurifier. Каталог создаётся автоматически и очищается вместе с остальным кэшем:

Специально чистить его после изменения списка разрешённых классов не нужно — этот список читается при каждой проверке.

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

Отдельный случай только один, и он касается ядра: если правится набор элементов в методе customizeDefinition() фабрики (политика RichContent), там же нужно поднять константу DEFINITION_REV. Иначе останется использоваться прежняя закэшированная версия правил, и изменение просто не подействует.

Последнее обновление

Это было полезно?