Очистка HTML (санитайзер)
Как безопасно выводить HTML, который написал пользователь
Если пользователь может писать текст с разметкой — сообщение на форуме, статью, описание файла, — этот текст нельзя выводить как есть. Вместе с оформлением в него легко попадает то, что выполнится в браузере другого посетителя: <script>, обработчики событий вроде onerror, ссылки со схемой javascript:. Убирает всё это санитайзер.
Главное правило
Чистим на выводе, а не при сохранении. В базе текст лежит ровно таким, каким его написал автор — это позволяет менять правила очистки без переписывания уже сохранённых данных и не ломает редактирование.
Не применяйте htmlspecialchars() к данным по пути в базу и не вешайте на модель каст SpecialChars. Экранирование при сохранении приводит к двойному экранированию на странице: посетитель видит <b> вместо жирного текста.
Как пользоваться
Внедрите 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:
Если текста нет, sanitize() вернёт пустую строку. Обычно в таком случае удобнее вернуть null, а не пустой Markup: объект всегда истинный, и проверка {% if x %} в шаблоне на пустом Markup не сработает.
Политики
Вызывающий код говорит, какого рода у него контент, а не как его чистить. Вид контента описывает Enum Johncms\Security\HtmlPolicy:
RichContent (по умолчанию)
сообщения форума, комментарии, статьи — всё, что написано в редакторе
блочные элементы, изображения, таблицы, встроенное медиа, разрешённые CSS-классы; голые ссылки превращаются в кликабельные
Inline
короткий текст внутри подписи или предложения
только инлайновое оформление и ссылки — ничто не разорвёт строку, в которой стоит
InlineWithParagraphs
короткий самостоятельный текст
то же плюс p и span
Политика передаётся вторым аргументом:
Пример из модуля согласий: заголовок согласия стоит рядом с чекбоксом в форме, поэтому ссылку в нём разрешить нужно, а абзац — нет, иначе вёрстка формы разъедется. Текст баннера cookie — отдельный блок, там абзацы уместны.
Обычный текст
toPlainText() — тот же контент, но без разметки: теги сняты, HTML-сущности раскодированы, идущие подряд пробелы схлопнуты. Это то, что нужно для превью в списке, заголовка страницы, хлебных крошек и meta description.
Не заменяйте это вызовом strip_tags() по сырому тексту. strip_tags() снимает теги, но оставляет их содержимое, поэтому содержимое <script> превратится в видимый текст страницы. Санитайзер сначала вырезает опасные элементы вместе с содержимым и только потом снимает оставшиеся теги.
Разрешённые CSS-классы
Для политики RichContent действует белый список классов из config/autoload/htmlpurifier.global.php:
Класс, которого нет в списке, вырезается из атрибута class, остальные остаются. Так пользователь не сможет разметить свой текст служебными классами темы и сломать вёрстку страницы. Правка списка применяется сразу, чистить кэш не нужно.
Не переопределяйте allowed_classes через htmlpurifier.local.php. Конфиги склеиваются функцией array_replace_recursive, а это список — элементы заменяются по номеру, а не добавляются:
Если классы нужны вашему модулю — не трогайте общий список, объявите свою политику (см. ниже): её классы задаются рядом с ней и ни с чем не конфликтуют.
Когда санитайзер не нужен
Он предназначен для разметки. Есть случаи, когда его применять не следует:
Адрес (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
Определение — это белый список: пустое определение снимает всю разметку, а расширить разрешённое сверх перечисленного оно не может.
Чего делать не нужно
Создавать свой санитайзер. Тогда правила очистки расползаются по модулям, и их нельзя ни сравнить, ни проверить одним ревью.
Рассчитывать на подстановку политики по умолчанию. Запрос политики с именем, которое никто не объявил, бросает
UnknownHtmlPolicyException. Это сделано намеренно: молчаливый откат к другой политике почистил бы текст по чужим правилам.Обращаться к встроенным политикам по имени.
'RichContent'как строка не сработает — к трём встроенным политикам ведёт только enumHtmlPolicy. Так модуль не сможет перехватить имя, по которому чистится контент всего сайта.
Кэш
Правила очистки компилируются один раз и кэшируются в data/cache/htmlpurifier. Каталог создаётся автоматически и очищается вместе с остальным кэшем:
Специально чистить его после изменения списка разрешённых классов не нужно — этот список читается при каждой проверке.
Политики модулей тоже ничего дополнительно чистить не требуют: изменили определение — новые правила действуют со следующего запроса.
Отдельный случай только один, и он касается ядра: если правится набор элементов в методе customizeDefinition() фабрики (политика RichContent), там же нужно поднять константу DEFINITION_REV. Иначе останется использоваться прежняя закэшированная версия правил, и изменение просто не подействует.
Последнее обновление
Это было полезно?