Latte – синоним безопасности

Latte – единственная система шаблонов для PHP с действенной защитой от критической уязвимости Cross-site Scripting (XSS). Это заслуга контекстно-зависимого экранирования. Мы обсудим:

  • принцип уязвимости XSS и почему она так опасна
  • что делает Latte таким эффективным в защите от XSS
  • как в Twig, Blade и подобных шаблонах легко возникают дыры в безопасности

Cross-site Scripting (XSS)

Cross-site Scripting (сокращённо XSS) – одна из самых частых и опасных уязвимостей веб-сайтов. Она позволяет злоумышленнику внедрить вредоносный скрипт (malware) в чужую страницу, который затем выполняется в браузере ничего не подозревающего пользователя.

Что может такой скрипт? Например, отправить злоумышленнику любое содержимое взломанной страницы, включая конфиденциальные данные, отображаемые после входа в систему. Он может изменить страницу или выполнить другие запросы от имени пользователя. Если бы это была веб-почта, он мог бы прочитать личные письма, изменить отображаемое содержимое или перенастроить параметры, например включить пересылку копий всех писем на адрес злоумышленника, чтобы получить доступ и к будущей переписке.

Именно поэтому XSS стабильно входит в число самых опасных уязвимостей. Если такая уязвимость появилась на сайте, её нужно устранить как можно скорее, чтобы ею не воспользовались.

Как возникает уязвимость?

Ошибка возникает там, где генерируется веб-страница и выводятся переменные. Представьте, что вы создаёте страницу поиска, в начале которой стоит абзац с искомым словом:

echo '<p>Search results for <em>' . $search . '</em></p>';

Злоумышленник может ввести в поле поиска, а значит и в переменную $search, любую строку, в том числе HTML-код вроде <script>alert("Hacked!")</script>. Поскольку вывод не обеззараживается, он становится частью отображаемой страницы:

<p>Search results for <em><script>alert("Hacked!")</script></em></p>

Вместо того чтобы показать искомую строку, браузер выполняет JavaScript. И злоумышленник получает контроль над страницей.

Вы можете возразить, что вставка кода в переменную выполняет JavaScript, но только в браузере самого злоумышленника. Как он попадает к жертве? С этой точки зрения различают несколько типов XSS. В нашем примере с поиском речь идёт об отражённом XSS. Здесь жертву нужно обманом заставить перейти по ссылке, содержащей вредоносный код в параметре:

https://example.com/?search=<script>alert("Hacked!")</script>

Заставить пользователя кликнуть по ссылке требует некоторой социальной инженерии, но это не так уж сложно. Пользователи переходят по ссылкам, будь то в письмах или в соцсетях, особо не задумываясь. А то, что в адресе есть что-то подозрительное, можно скрыть с помощью сокращателя ссылок: пользователь увидит лишь bit.ly/xxx.

Но есть и вторая, гораздо более опасная форма атаки, известная как хранимый XSS или постоянный XSS, когда злоумышленнику удаётся сохранить вредоносный код на сервере так, что он автоматически вставляется в определённые страницы.

Пример – страницы, где пользователи пишут комментарии. Злоумышленник отправляет сообщение с кодом, и оно сохраняется на сервере. Если страницы недостаточно защищены, код затем выполнится в браузере каждого посетителя.

Может показаться, что суть атаки в том, чтобы протащить в страницу строку <script>. На самом деле способов вставить JavaScript очень много. Покажем пример с HTML-атрибутом. Представьте фотогалерею, где к изображениям можно добавлять подписи, отображаемые в атрибуте alt:

echo '<img src="' . $imageFile . '" alt="' . $imageAlt . '">';

Злоумышленнику достаточно вставить в подпись хитро составленную строку " onload="alert('Hacked!'), и если вывод не обеззараживается, получившийся код будет выглядеть так:

<img src="photo0145.webp" alt="" onload="alert('Hacked!')">

Подсунутый атрибут onload теперь становится частью страницы. Браузер выполнит содержащийся в нём код сразу после загрузки изображения. Взломано!

Как защититься от XSS?

Любые попытки обнаружить атаки по чёрным спискам, например заблокировать строку <script>, недостаточны. Основа работающей защиты – последовательное обеззараживание всех данных, выводимых на странице.

Прежде всего речь о замене всех символов со специальным значением на соответствующие последовательности, что в обиходе называют экранированием (первый символ последовательности называется экранирующим, отсюда и название). Например, в HTML-тексте символ < имеет специальное значение; если он не должен восприниматься как начало тега, мы обязаны заменить его визуально соответствующей последовательностью, HTML-сущностью &lt;. Тогда браузер отобразит знак “меньше”.

Принципиально важно различать контекст, в котором данные выводятся. Потому что в разных контекстах строки обеззараживаются по-разному. В разных контекстах специальное значение имеют разные символы. Например, экранирование различается в HTML-тексте, в HTML-атрибутах, внутри некоторых специальных элементов и так далее. Подробно мы разберём это чуть ниже.

Обеззараживание лучше всего выполнять прямо в момент вывода строки на страницу: так гарантируется, что оно действительно произойдёт и ровно один раз. Ещё лучше, если обеззараживанием автоматически занимается сама система шаблонов. Ведь если оно не автоматическое, программист может о нём забыть. А один недосмотр означает, что сайт уязвим.

Впрочем, XSS касается не только вывода данных в шаблонах, но и других частей приложения, которые должны правильно обращаться с недоверенными данными. Например, JavaScript в вашем приложении должен использовать с недоверенными данными innerText или textContent, а не innerHTML. Особое внимание нужно уделять функциям, вычисляющим строки как JavaScript: eval(), а также setTimeout(), или использованию setAttribute() с атрибутами событий вроде onload и подобных. Однако это выходит за пределы того, чем занимаются шаблоны.

Идеальная защита в 3 пунктах:

  1. Распознаёт контекст, в котором выводятся данные.
  2. Обеззараживает данные по правилам этого контекста (то есть “учитывает контекст”).
  3. Делает это автоматически.

Контекстно-зависимое экранирование

Что именно понимается под словом “контекст”? Это место внутри документа со своими правилами обращения с выводимыми данными. Оно зависит от типа документа (HTML, XML, CSS, JavaScript, простой текст, …) и может различаться в отдельных его частях. Например, в HTML-документе есть множество мест (контекстов), где действуют совершенно разные правила. Вы удивитесь, как их много. Вот первые четыре:

<p>#text</p>
<img src="#attribute">
<textarea>#rawtext</textarea>
<!-- #comment -->

Стандартный и базовый контекст HTML-страницы – это HTML-текст. Какие правила действуют здесь? Символы < и & имеют специальное значение, обозначая начало тега или сущности, поэтому мы обязаны экранировать их, заменяя HTML-сущностями (< превращается в &lt;, & – в &amp;).

Второй по частоте контекст – значение HTML-атрибута. От текста он отличается тем, что здесь специальное значение имеет кавычка " или ', ограничивающая атрибут. Её нужно записать как сущность, чтобы она не была истолкована как конец атрибута. И наоборот, символ < внутри атрибута можно использовать спокойно, потому что там у него нет специального значения: он не может быть истолкован как начало тега или комментария. Но осторожно: в HTML значения атрибутов можно писать и без кавычек, и тогда специальное значение приобретает целый ряд символов, что делает это ещё одним отдельным контекстом.

Вы удивитесь, но особые правила действуют внутри элементов <textarea> и <title>, где символ < не обязан (но может) экранироваться, если за ним не следует /. Впрочем, это скорее мелкая деталь.

Интереснее становится внутри HTML-комментариев. Здесь HTML-сущности для экранирования не применяются. Более того, ни одна спецификация не говорит, как в комментариях следует экранировать. Нужно лишь соблюдать несколько любопытных правил и избегать внутри них определённых сочетаний символов.

Контексты могут и накладываться друг на друга – это происходит, когда мы встраиваем в HTML код JavaScript или CSS. Сделать это можно двумя разными способами, через элемент или через атрибут:

<script>#js-element</script>
<img onclick="#js-attribute">

<style>#css-element</style>
<p style="#css-attribute"></p>

Два пути и два разных способа экранирования данных. Внутри элементов <script> и <style>, как и в HTML-комментариях, экранирование HTML-сущностями не выполняется. При выводе данных внутри этих элементов нужно соблюдать лишь одно правило: текст не должен содержать последовательность </script или, соответственно, </style.

И наоборот, в атрибутах style и on*** экранирование выполняется HTML-сущностями.

И, конечно, внутри вложенного JavaScript или CSS действуют правила экранирования этих языков. Поэтому строка в атрибуте вроде onload сначала экранируется по правилам JS, а затем по правилам HTML-атрибута.

Уф… Как видите, HTML – очень сложный документ, где контексты наслаиваются друг на друга, и, не осознавая точно, где вы выводите данные (то есть в каком контексте), нельзя сказать, как сделать это правильно.

Хотите пример?

Возьмём строку Rock'n'Roll.

Если вы выводите её в HTML-тексте, в данном конкретном случае никакой замены не нужно, потому что строка не содержит символов со специальным значением. Ситуация изменится, если вы выводите её внутри HTML-атрибута, заключённого в одинарные кавычки. Тогда кавычки нужно экранировать в HTML-сущности:

<div title='Rock&apos;n&apos;Roll'></div>

Это было просто. Куда интереснее становится, когда контексты наслаиваются, например если строка становится частью JavaScript.

Сначала выведем её в самом JavaScript. То есть заключим в кавычки и одновременно экранируем содержащиеся в ней кавычки символом \:

'Rock\'n\'Roll'

Мы можем добавить и вызов функции, чтобы код что-то делал:

alert('Rock\'n\'Roll');

Если мы вставим этот код в HTML-документ через <script>, никаких дальнейших изменений не потребуется, потому что он не содержит запрещённой последовательности </script:

<script> alert('Rock\'n\'Roll'); </script>

Но если бы мы захотели вставить его в HTML-атрибут, нам всё же пришлось бы экранировать кавычки в HTML-сущности:

<div onclick='alert(&apos;Rock\&apos;n\&apos;Roll&apos;)'></div>

При этом вложенным контекстом может быть не только JS или CSS. Часто это и URL. Параметры в URL экранируются превращением символов со специальным значением в последовательности, начинающиеся с %. Пример:

https://example.org/?a=Jazz&b=Rock%27n%27Roll

А когда мы выводим эту строку в атрибуте, мы дополнительно применяем экранирование по правилам этого контекста и заменяем & на &amp;:

<a href="https://example.org/?a=Jazz&amp;b=Rock%27n%27Roll">

Если вы дочитали досюда, поздравляем, это было исчерпывающе. Теперь вы хорошо понимаете, что такое контексты и экранирование. И не переживайте, что это сложно. Latte делает всё это за вас автоматически.

Latte против наивных систем

Мы показали, как правильно экранировать в HTML-документе и насколько принципиально важно знать контекст, то есть место, где мы выводим данные. Иными словами, как работает контекстно-зависимое экранирование. И хотя это необходимое условие работающей защиты от XSS, Latte – единственная система шаблонов для PHP, которая это умеет.

Как такое возможно, если сегодня все системы заявляют об автоматическом экранировании? Автоматическое экранирование без знания контекста – это, в общем-то, заблуждение, которое создаёт ложное чувство безопасности.

Системы шаблонов вроде Twig, Laravel Blade и других не видят в шаблоне никакой структуры HTML. Поэтому они не видят и контекстов. По сравнению с Latte они слепы и наивны. Они обрабатывают только собственные теги, всё остальное для них – незначительный поток символов:

░░░░░░░░░░░░░░░░░{{ foo }}░░░░░░░
░░░░░░░░░░░░░░░░{{ foo }}░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░{{ foo }}░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░{{ foo }}░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░{{ foo }}░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░{{ foo }}░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░{{ foo }}░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░{{ foo }}░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░{{ foo }}░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░{{ foo }}░░░░
- in text: <span>{{ foo }}</span>
- in tag: <span {{ foo }} ></span>
- in attribute: <span title='{{ foo }}'></span>
- in unquoted attribute: <span title={{ foo }}></span>
- in attribute containing URL: <a href="{{ foo }}"></a>
- in attribute containing JavaScript: <img onload="{{ foo }}">
- in attribute containing CSS: <span style="{{ foo }}"></span>
- in JavaScript: <script>var = {{ foo }}</script>
- in CSS: <style>body { content: {{ foo }}; }</style>
- in comment: <!-- {{ foo }} -->

Наивные системы просто механически превращают символы < > & ' " в HTML-сущности, и хотя в большинстве случаев это корректный способ экранирования, его далеко не всегда достаточно. Поэтому они не способны обнаружить или предотвратить возникновение самых разных дыр в безопасности, как мы покажем ниже.

Latte видит шаблон так же, как вы. Он понимает HTML и XML, распознаёт теги, атрибуты и так далее. Благодаря этому он различает отдельные контексты и обращается с данными соответственно. Тем самым он предлагает по-настоящему действенную защиту от критической уязвимости Cross-site Scripting.

░░░░░░░░░░░<span>{$foo}</span>
░░░░░░░░░░<span {$foo} ></span>
░░░░░░░░░░░░░░░░<span title='{$foo}'></span>
░░░░░░░░░░░░░░░░░░░░░░░░░<span title={$foo}></span>
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░<a href="{$foo}"></a>
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░<img onload="{$foo}">
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░<span style="{$foo}"></span>
░░░░░░░░░░░░░░░░░░<script>░░░░░░{$foo}</script>
░░░░░░░░░░<style>░░░░░░░░░░░░░░░░{$foo}░░░</style>
░░░░░░░░░░░░░░<!--░{$foo}░-->
- in text: <span>{$foo}</span>
- in tag: <span {$foo} ></span>
- in attribute: <span title='{$foo}'></span>
- in unquoted attribute: <span title={$foo}></span>
- in attribute containing URL: <a href="{$foo}"></a>
- in attribute containing JavaScript: <img onload="{$foo}">
- in attribute containing CSS: <span style="{$foo}"></span>
- in JavaScript: <script>var = {$foo}</script>
- in CSS: <style>body { content: {$foo}; }</style>
- in comment: <!-- {$foo} -->

Живая демонстрация

Слева вы видите шаблон на Latte, справа – сгенерированный HTML-код. Переменная $text выводится несколько раз, каждый раз в чуть другом контексте. И потому экранируется чуть по-разному. Вы можете сами изменить код шаблона, например поменять содержимое переменной. Попробуйте:

{* ПОПРОБУЙТЕ ИЗМЕНИТЬ ЭТОТ ШАБЛОН *}
{var $text = "Rock'n'Roll"}
- <span>{$text}</span>
- <span title='{$text}'></span>
- <span title={$text}></span>
- <img onload="{$text}">
- <script>var = {$text}</script>
- <!-- {$text} -->
- <span>Rock'n'Roll</span>
- <span title='Rock&apos;n&apos;Roll'></span>
- <span title="Rock&apos;n&apos;Roll"></span>
- <img onload="&quot;Rock&apos;n&apos;Roll&quot;">
- <script>var = "Rock'n'Roll"</script>
- <!-- Rock'n'Roll -->

Разве не здорово! Latte выполняет контекстно-зависимое экранирование автоматически, поэтому программисту:

  • не нужно думать о том, где и как экранировать, и знать это
  • невозможно ошибиться
  • невозможно забыть об экранировании

И это ещё не все контексты, которые Latte различает при выводе и под которые подстраивает обращение с данными. Сейчас мы разберём другие интересные случаи.

Как взломать наивные системы

На нескольких практических примерах мы покажем, насколько важно различать контексты и почему наивные системы шаблонов, в отличие от Latte, не дают достаточной защиты от XSS. В примерах в роли представителя наивной системы выступит Twig, но то же самое относится и к другим системам.

Уязвимость в атрибуте

Попробуем внедрить в страницу вредоносный код через HTML-атрибут, как мы показали выше. Пусть у нас есть шаблон на Twig, выводящий изображение:

<img src={{ imageFile }} alt={{ imageAlt }}>

Обратите внимание, что вокруг значений атрибутов нет кавычек. Верстальщик мог о них забыть, такое просто случается. Например, в React код пишут именно так, без кавычек, и верстальщик, переключающийся между языками, легко может забыть кавычки.

Злоумышленник вставляет в подпись изображения хитро составленную строку foo onload=alert('Hacked!'). Мы уже знаем, что Twig не в состоянии определить, выводится ли переменная в потоке HTML-текста, внутри атрибута, в HTML-комментарии и так далее; коротко говоря, он не различает контексты. И он просто механически превращает символы < > & ' " в HTML-сущности. Поэтому получившийся код будет выглядеть так:

<img src=photo0145.webp alt=foo onload=alert(&#039;Hacked!&#039;)>

Дыра в безопасности возникла!

Подделанный атрибут onload стал частью страницы, и браузер выполнит его сразу после загрузки изображения.

Теперь посмотрим, как с тем же шаблоном справится Latte:

<img src={$imageFile} alt={$imageAlt}>

Latte видит шаблон так же, как вы. В отличие от Twig, он понимает HTML и знает, что переменная выводится как значение атрибута, не заключённого в кавычки. Поэтому он их добавляет. Когда злоумышленник вставит ту же подпись, получившийся код будет выглядеть так:

<img src="photo0145.webp" alt="foo onload=alert(&apos;Hacked!&apos;)">

Latte успешно предотвратил XSS.

Вывод переменной в JavaScript

Благодаря контекстно-зависимому экранированию переменные PHP можно естественно использовать внутри JavaScript.

<p onclick="alert({$movie})">{$movie}</p>

<script>var movie = {$movie};</script>

Если переменная $movie содержит строку 'Amarcord & 8 1/2', будет сгенерирован следующий вывод. Обратите внимание на разное экранирование внутри HTML и внутри JavaScript и на ещё одно, третье экранирование в атрибуте onclick:

<p onclick="alert(&quot;Amarcord &amp; 8 1/2&quot;)">Amarcord &amp; 8 1/2</p>

<script>var movie = "Amarcord & 8 1/2";</script>

Проверка ссылок

Latte автоматически проверяет, содержит ли переменная, использованная в атрибутах с URL, таких как src, href, action, formaction или data у элемента <object>, безопасный URL. Он разрешает распространённые протоколы (http, https, ftp, mailto, tel, sms), а также относительные URL и блокирует потенциально опасные, например javascript:.

{var $link = 'javascript:attack()'}

<a href={$link}>click here</a>

Выводит:

<a href="">click here</a>

Проверку можно отключить фильтром nocheck.

Клиентские фреймворки

Фреймворки вроде Vue или Angular считают часть страницы своим собственным шаблоном и истолковывают внутри неё двойные фигурные скобки {{ ... }} как выражение. Если злоумышленнику удастся протащить такую последовательность в выводимую переменную, фреймворк вычислит её в браузере после загрузки страницы, хотя на стороне сервера никакого XSS не произошло.

Latte это предотвращает. При выводе в HTML-текст он превращает пару {{ в {<!-- -->{; пустой комментарий невидим читателю, но не даёт фреймворку распознать собственный тег. Любое другое вхождение символа { заменяется сущностью &#123; (внутри значений атрибутов так экранируется каждая {).

{var $query = 'Hi {{ constructor.constructor("alert(1)")() }}'}

<h1>Search: {$query}</h1>

Выводит:

<h1>Search: Hi {<!-- -->{ constructor.constructor("alert(1)")() }}</h1>

Защита работает автоматически и не требует настройки.

Пределы возможностей Latte

Latte – это не полная защита от XSS для всего приложения. Нам было бы жаль, если бы вы перестали думать о безопасности, используя Latte. Цель Latte в том, чтобы злоумышленник не смог изменить структуру страницы, подделать HTML-элементы или атрибуты. Но он не проверяет содержательную правильность выводимых данных. И правильность поведения JavaScript тоже. Это выходит за пределы компетенции системы шаблонов. Проверка правильности данных, особенно введённых пользователем и потому недоверенных, – важная задача программиста.

Хотите проверить свои знания об экранировании в отдельных контекстах? Пройдите викторину об уязвимости XSS.

версия: 3.x