> For the complete documentation index, see [llms.txt](https://docs.johncms.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.johncms.com/10.0/obshie-svedeniya/html-sanitizer.md).

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

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

{% hint style="info" %}
Текст, который выводится на страницу как контент — сообщение форума, комментарий, статья, — чистится не отдельным вызовом санитайзера, а [конвейером контента](/10.0/obshie-svedeniya/content-pipeline.md): он и очищает разметку, и рисует медиа со смайлами. Эта страница — про остальные случаи: значение чистят, но не выводят как контент.
{% endhint %}

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

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

{% hint style="danger" %}
Не применяйте `htmlspecialchars()` к данным по пути в базу и не вешайте на модель каст `SpecialChars`. Экранирование при сохранении приводит к двойному экранированию на странице: посетитель видит `&lt;b&gt;` вместо жирного текста.
{% endhint %}

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

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

```php
<?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
{{ article.formatted_text }}
```

{% hint style="info" %}
Twig экранирует всё, что печатает через `{{ }}` (см. [Создание собственного шаблона](/10.0/shablony/sozdanie-sobstvennogo-shablona.md)). Исключение — значения, которые являются разметкой по договорённости, то есть `Markup`. Поэтому `|raw` для очищенного текста не нужен: если он понадобился, значит сервис вернул строку вместо `Markup`.
{% endhint %}

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

```php
public function format(?string $text): ?Markup
{
    $clean = $this->sanitizer->sanitize((string) $text);

    return $clean === '' ? null : new Markup($clean, 'UTF-8');
}
```

## Политики

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

| Политика                     | Для чего                                                              | Что разрешает                                                                                                              |
| ---------------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `RichContent` (по умолчанию) | сообщения форума, комментарии, статьи — всё, что написано в редакторе | блочные элементы, изображения, таблицы, встроенное медиа, разрешённые CSS-классы; голые ссылки превращаются в кликабельные |
| `Inline`                     | короткий текст внутри подписи или предложения                         | только инлайновое оформление и ссылки — ничто не разорвёт строку, в которой стоит                                          |
| `InlineWithParagraphs`       | короткий самостоятельный текст                                        | то же плюс `p` и `span`                                                                                                    |

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

```php
$this->sanitizer->sanitize($title, HtmlPolicy::Inline);
```

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

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

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

```php
$preview = $this->sanitizer->toPlainText($article->text);
```

{% hint style="warning" %}
Не заменяйте это вызовом `strip_tags()` по сырому тексту. `strip_tags()` снимает теги, но оставляет их содержимое, поэтому содержимое `<script>` превратится в видимый текст страницы. Санитайзер сначала вырезает опасные элементы вместе с содержимым и только потом снимает оставшиеся теги.
{% endhint %}

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

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

```php
return [
    'htmlpurifier' => [
        'allowed_classes' => [
            'alert',
            'alert-info',
            // ...
        ],
    ],
];
```

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

{% hint style="warning" %}
Не переопределяйте `allowed_classes` через `htmlpurifier.local.php`. Конфиги склеиваются функцией `array_replace_recursive`, а это список — элементы заменяются по номеру, а не добавляются:

```
global: ['alert', 'alert-info', 'media']
local:  ['my-class']
итог:   ['my-class', 'alert-info', 'media']   ← 'alert' потерялся
```

Если классы нужны вашему модулю — не трогайте общий список, объявите свою политику (см. ниже): её классы задаются рядом с ней и ни с чем не конфликтуют.
{% endhint %}

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

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

* **Адрес (URL).** Ссылку нельзя обезопасить очисткой разметки — её защищает проверка схемы. Разрешите `http` и `https` (либо локальный путь) и соберите ссылку сами. Пример — `UserMutators::getWebsiteAttribute()`.
* **Значение, которое выводится как текст.** Имя, город, номер телефона печатаются через `{{ }}`, и Twig экранирует их сам. Прогонять их через санитайзер незачем.
* **Текст, который пишет только разработчик.** Строки перевода и литералы в шаблонах не приходят извне.

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

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

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

```php
<?php

declare(strict_types=1);

namespace Johncms\Modules\MyModule\Application\Security;

use Johncms\Security\HtmlPolicyDefinition;
use Johncms\Security\HtmlPolicyProviderInterface;

final class MyModuleHtmlPolicies implements HtmlPolicyProviderInterface
{
    public function policies(): iterable
    {
        yield new HtmlPolicyDefinition(
            name: 'my-module.signature',
            elements: [
                'a'    => ['href', 'title', 'target', 'rel'],
                'b'    => [],
                'br'   => [],
                'span' => ['class'],
            ],
            allowedClasses: ['signature'],
        );
    }
}
```

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

```php
$this->sanitizer->sanitize($signature, 'my-module.signature');
```

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

| Поле             | Значение                                                                                                      |
| ---------------- | ------------------------------------------------------------------------------------------------------------- |
| `name`           | Уникальное имя политики. Соглашение — `<модуль>.<контент>`, чтобы имена модулей не сталкивались               |
| `elements`       | Разрешённые элементы и их атрибуты: `['a' => ['href'], 'b' => []]`. Всё, что не перечислено, вырезается       |
| `allowedClasses` | Имена классов, которые выживут в атрибуте `class`. `null` — пропускать любые, `[]` (по умолчанию) — ни одного |
| `linkify`        | Превращать ли голые ссылки в тексте в кликабельные                                                            |
| `linkSchemes`    | Схемы, разрешённые в ссылках. По умолчанию `http`, `https`, `mailto`                                          |
| `frameTargets`   | Значения, допустимые в атрибуте `target`. По умолчанию только `_blank`                                        |

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

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

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

* **Создавать свой санитайзер.** Тогда правила очистки расползаются по модулям, и их нельзя ни сравнить, ни проверить одним ревью.
* **Рассчитывать на подстановку политики по умолчанию.** Запрос политики с именем, которое никто не объявил, бросает `UnknownHtmlPolicyException`. Это сделано намеренно: молчаливый откат к другой политике почистил бы текст по чужим правилам.
* **Обращаться к встроенным политикам по имени.** `'RichContent'` как строка не сработает — к трём встроенным политикам ведёт только enum `HtmlPolicy`. Так модуль не сможет перехватить имя, по которому чистится контент всего сайта.

## Кэш

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

```bash
php system/bin/console cache:clear
```

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

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

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