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-сущностью <. Тогда браузер
отобразит знак “меньше”.
Принципиально важно различать контекст, в котором данные выводятся. Потому что в разных контекстах строки обеззараживаются по-разному. В разных контекстах специальное значение имеют разные символы. Например, экранирование различается в HTML-тексте, в HTML-атрибутах, внутри некоторых специальных элементов и так далее. Подробно мы разберём это чуть ниже.
Обеззараживание лучше всего выполнять прямо в момент вывода строки на страницу: так гарантируется, что оно действительно произойдёт и ровно один раз. Ещё лучше, если обеззараживанием автоматически занимается сама система шаблонов. Ведь если оно не автоматическое, программист может о нём забыть. А один недосмотр означает, что сайт уязвим.
Впрочем, XSS касается не только вывода данных в шаблонах, но и других
частей приложения, которые должны правильно обращаться с
недоверенными данными. Например, JavaScript в вашем приложении должен
использовать с недоверенными данными innerText или textContent, а
не innerHTML. Особое внимание нужно уделять функциям, вычисляющим
строки как JavaScript: eval(), а также setTimeout(), или использованию
setAttribute() с атрибутами событий вроде onload и подобных.
Однако это выходит за пределы того, чем занимаются шаблоны.
Идеальная защита в 3 пунктах:
- Распознаёт контекст, в котором выводятся данные.
- Обеззараживает данные по правилам этого контекста (то есть “учитывает контекст”).
- Делает это автоматически.
Контекстно-зависимое экранирование
Что именно понимается под словом “контекст”? Это место внутри документа со своими правилами обращения с выводимыми данными. Оно зависит от типа документа (HTML, XML, CSS, JavaScript, простой текст, …) и может различаться в отдельных его частях. Например, в HTML-документе есть множество мест (контекстов), где действуют совершенно разные правила. Вы удивитесь, как их много. Вот первые четыре:
<p>#text</p>
<img src="#attribute">
<textarea>#rawtext</textarea>
<!-- #comment -->
Стандартный и базовый контекст HTML-страницы – это HTML-текст. Какие
правила действуют здесь? Символы < и & имеют
специальное значение, обозначая начало тега или сущности, поэтому мы
обязаны экранировать их, заменяя HTML-сущностями (< превращается
в <, & – в &).
Второй по частоте контекст – значение 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'n'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('Rock\'n\'Roll')'></div>
При этом вложенным контекстом может быть не только JS или CSS. Часто это
и URL. Параметры в URL экранируются превращением символов со специальным
значением в последовательности, начинающиеся с %. Пример:
https://example.org/?a=Jazz&b=Rock%27n%27Roll
А когда мы выводим эту строку в атрибуте, мы дополнительно применяем
экранирование по правилам этого контекста и заменяем & на
&:
<a href="https://example.org/?a=Jazz&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 выводится несколько раз, каждый раз в чуть другом
контексте. И потому экранируется чуть по-разному. Вы можете сами
изменить код шаблона, например поменять содержимое переменной.
Попробуйте:
Разве не здорово! 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('Hacked!')>
Дыра в безопасности возникла!
Подделанный атрибут onload стал частью страницы, и браузер
выполнит его сразу после загрузки изображения.
Теперь посмотрим, как с тем же шаблоном справится Latte:
<img src={$imageFile} alt={$imageAlt}>
Latte видит шаблон так же, как вы. В отличие от Twig, он понимает HTML и знает, что переменная выводится как значение атрибута, не заключённого в кавычки. Поэтому он их добавляет. Когда злоумышленник вставит ту же подпись, получившийся код будет выглядеть так:
<img src="photo0145.webp" alt="foo onload=alert('Hacked!')">
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("Amarcord & 8 1/2")">Amarcord & 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-текст он превращает пару
{{ в {<!-- -->{; пустой комментарий невидим читателю, но не
даёт фреймворку распознать собственный тег. Любое другое вхождение
символа { заменяется сущностью { (внутри значений
атрибутов так экранируется каждая {).
{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.