<? phpukraine СПІВБЕСІДИ
⌕Пошук по платформі
SYMFONY · JUNIOR

Як Twig захищає від XSS і коли автоекранування треба вимкнути?

Twig за замовчуванням екранує кожен вивід `{{ }}`, але стратегію обирає за розширенням файлу шаблону (`.html.twig` - html, `.js.twig` - js, `.txt.twig` - без екранування), а не за місцем виразу в розмітці; тому в атрибутах, URL-параметрах і в `<script>` потрібні явні `|e('html_attr')`, `|e('url')`, `|e('js')`, а `|raw` лишають тільки для HTML, який згенерував сам застосунок або пропустив через санітайзер.

Twig же екранує все сам, навіщо тоді фільтр `e`?
У нас на сторінці спрацював `alert()` з нікнейму користувача, хоча `raw` ніде немає. Як таке могло статися?
Що виведе `{{ '<b>привіт</b>' }}` у файлі `report.txt.twig` і чим це відрізняється від `report.html.twig`?
Дизайнер просить дозволити користувачам жирний шрифт у біо. Як це зробити і не відкрити XSS?
Twig XSS autoescape escape шаблони

Автоекранування в Symfony увімкнене з коробки: twig.autoescape за замовчуванням має значення name. Спрацьовує воно на етапі компіляції шаблону, коли кожен {{ ... }} Twig перетворює на виклик escaper'а з конкретною стратегією. Саму стратегію дає FileExtensionEscapingStrategy, дивлячись на ім'я файлу: .html.twig дає html, .js.twig дає js, .css.twig дає css, а .txt.twig не дає нічого, там екранування вимкнене. Під html ховається htmlspecialchars з ENT_QUOTES | ENT_SUBSTITUTE і кодуванням UTF-8, тож <, >, &, подвійні й одинарні лапки їдуть у сутності. Тому звичайний {{ user.name }} на HTML-сторінці не потребує від вас нічого додаткового.

На співбесіді валяться на іншому: Twig не знає, де саме стоїть ваш вираз. Він не бачить ні того, що навколо <script>, ні того, що атрибут написали без лапок, ні того, що це href. Стратегія одна на весь файл. Звідси й набір явних фільтрів для нестандартних контекстів. |e('html_attr') екранує все, крім букв і цифр, тому переживає навіть атрибут без лапок; |e('js') перетворює символи на \xHH і \uHHHH для вставки в JS-рядок; |e('css') робить те саме для значень у стилях; |e('url') це rawurlencode для частини URL. Останній регулярно розуміють неправильно: він кодує параметр, а не перевіряє схему посилання, тож javascript:alert(1) у полі «мій сайт» ним не вилікуєш, схему валідують у PHP і зберігають уже перевірене значення. Коли цілий блок шаблону пишеться під інший контекст, є тег {% autoescape 'js' %} ... {% endautoescape %}.

Вимикати екранування доводиться рідко, і привід завжди один: значення вже є HTML, який ми свідомо хочемо показати. Легальні джерела такого HTML: власний рендер Markdown, згенерована в коді розмітка, контент користувача, пропущений через HtmlSanitizer з пакета symfony/html-sanitizer (є й Twig-фільтр sanitize_html, коли чистити зручніше на рівні шаблону). Тут {{ value|raw }} виправданий. А от «на сторінці видно &amp; замість &» приводом не є. Це симптом подвійного екранування: дані приходять уже обробленими з контролера або лежать екранованими в базі. raw у такій ситуації ховає справжню проблему й заразом відкриває XSS на решті полів того самого тексту. {% autoescape false %} на півшаблону - та сама помилка, тільки масштабніша.

Успадкування шаблонів має до безпеки прямішу дотичність, ніж здається. Коли базовий base.html.twig описує каркас, а сторінки перевизначають {% block %} і за потреби дотягують батьківський вміст через {{ parent() }}, розмітка не розмножується копіпастою, а разом із нею не розмножуються й рішення на кшталт raw. Для повторюваних фрагментів із власною структурою зручний {% embed %}: той самий include, але з можливістю перевизначити блоки всередині. А {% include %} тримає пастку - підшаблон має власну стратегію екранування за своїм іменем, і вивід тега через escaper не проходить. Підключений у HTML-сторінку partials/footer.txt.twig вставить дані користувача так, як вони є.

Логіка в шаблоні - окрема тема, яку інтерв'юер зазвичай чіпляє тим самим питанням. Конкатенація рядків, умовне форматування чисел, склейка URL руками: усе це переїжджає у Twig-розширення. Клас на AbstractExtension описує фільтр, а реалізація живе в окремому runtime-класі з RuntimeExtensionInterface, щоб сервіси не піднімалися на кожен рендер. Symfony автоконфігурує обидва теги, тож достатньо створити клас. Критичний там один момент: ['is_safe' => ['html']] в описі фільтра означає, що Twig результат не екрануватиме, і відповідальність за htmlspecialchars переходить на ваш код. Позначити фільтр безпечним і конкатенувати в ньому нікнейм користувача - найтихіший зі способів зламати сторінку, бо в шаблоні при цьому ані raw, ані autoescape false не видно.

{# templates/profile/show.html.twig
   Стратегію екранування Twig бере з розширення файлу: .html.twig -> html #}
{% extends 'layout/base.html.twig' %}

{% block title %}Профіль {{ user.name }}{% endblock %}   {# екранується автоматично #}

{% block body %}
  {{ parent() }}   {# спільна розмітка з базового шаблону, а не її копія #}

  <h1>{{ user.name }}</h1>   {# htmlspecialchars викликається сам #}

  {# Атрибут: html_attr закриває і випадок атрибута без лапок #}
  <span data-nick="{{ user.nick|e('html_attr') }}">{{ user.nick }}</span>

  {# Параметр URL кодуємо окремо; e('url') це rawurlencode, а не перевірка схеми #}
  <a href="/articles?author={{ user.nick|e('url') }}">усі статті</a>

  {# Чуже посилання: схему http/https перевірено в PHP, тут вистачає звичайного екранування #}
  <a href="{{ user.siteUrl }}" rel="nofollow noopener">сайт</a>

  {# Дані для JS - через data-атрибут, а не вставкою всередину <script> #}
  <div id="chart" data-points="{{ stats.points|json_encode|e('html_attr') }}"></div>

  {# raw лише для HTML, який уже пройшов HtmlSanitizer у контролері #}
  <div class="bio">{{ user.safeBioHtml|raw }}</div>

  {# Форматування живе у Twig-розширенні, а не в шаблоні #}
  <p>З нами {{ user.registeredAt|human_since }}</p>
{% endblock %}
Що автоекранування в Symfony увімкнене стратегією `name`: вона дивиться на розширення файлу (`.html.twig`, `.js.twig`, `.css.twig`, `.txt.twig`), а не аналізує розмітку навколо виразу.
Що html-екранування не є універсальним: усередині `<script>`, у CSS, у неокутому лапками атрибуті й у частині URL потрібні інші стратегії - `js`, `css`, `html_attr`, `url`.
Що `|raw` допустимий лише для HTML, який згенерував наш код або який пройшов санітайзер (`symfony/html-sanitizer`), і ніколи - «щоб полагодити `&amp;` у виводі».
Що успадкування шаблонів (`extends` + `block` + `parent()`) і `embed` тримають розмітку в одному місці, тому й рішення про `raw` не доводиться копіювати по десятку файлів.
Що форматування й логіка виносяться у Twig-розширення з runtime-класом, і що прапорець `is_safe` знімає автоекранування - отже, екранувати зобов'язаний сам код розширення.
Ставити `|raw`, побачивши `&amp;` або `&quot;` на сторінці: майже завжди причина в тому, що дані вже прийшли екранованими з БД чи з контролера, і лікувати треба джерело.
Вважати `{{ token }}` усередині `<script>` безпечним: html-стратегія не чіпає зворотний слеш, тому значення, що закінчується на `\`, з'їдає закривальну лапку JS-рядка і відкриває вихід у код.
Гадати, що `|e('url')` знешкодить `javascript:` у чужому посиланні. Стратегія `url` - це `rawurlencode` для параметра; схему `http`/`https` перевіряють у PHP, а не в шаблоні.
Закривати `{% autoescape false %}` цілий блок розмітки, коли небезпечний рівно один вираз.
Позначити власний фільтр `['is_safe' => ['html']]` і склеювати в ньому рядок з даними користувача без `htmlspecialchars` - автоекранування після цього вже не спрацює.
ПОРАДА

Проговоріть вголос, звідки береться стратегія: «Twig екранує за розширенням файлу, а не за місцем виразу в розмітці». Далі одна фраза про `raw` - «тільки для HTML, який зробили ми самі або санітайзер» - і питання зазвичай закрите.

оновлено 3 жовтня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Стратегію дає `FileExtensionEscapingStrategy` за іменем файлу, позицію виразу в розмітці Twig не знає; `striptags` вирізає теги, але не робить довільний HTML безпечним - для цього є HtmlSanitizer.