Инъекции в шаблоны (SSTI)

Понимаем, как пользовательский ввод, попавший в шаблонизатор, превращается в исполняемый код, и как этого не допустить.

SSTI (Server-Side Template Injection) — уязвимость, при которой ввод пользователя попадает не в данные шаблона, а в сам текст шаблона, и движок исполняет его как выражение.

Шаблонизатор (Jinja2, Twig, Freemarker, ERB и другие) — это движок, который берёт шаблон с плейсхолдерами и подставляет в них данные. Нормальный поток такой: разработчик пишет шаблон, а данные передаёт отдельно, как значения. SSTI возникает, когда разработчик по ошибке склеивает пользовательский ввод с текстом шаблона, а затем компилирует результат. Тогда движок воспринимает ввод как часть шаблонного языка — а шаблонные языки умеют вычислять выражения и обращаться к свойствам объектов. Это относится к категории A03: Injection в OWASP Top 10.

Как и любую инъекцию, SSTI мы разбираем, чтобы закрывать её в своём коде. Цель — увидеть опасный паттерн и заменить его на безопасный, а не атаковать чужие приложения (это статья 272 УК РФ).

Зачем это знать защитнику

SSTI коварна тем, что маскируется под «обычную подстановку строки». Разработчик хочет сделать персональное приветствие или письмо с именем пользователя и неосторожно вставляет имя прямо в шаблон. Внешне ничего не выдаёт уязвимость — пока кто-то не пришлёт вместо имени шаблонное выражение. Понимая механику, вы держите границу «шаблон — это код, данные передаются отдельно» и не ставите ввод на сторону кода.

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

Сравним два способа использовать шаблонизатор. Безопасный — когда шаблон фиксирован, а ввод приходит как значение переменной. Уязвимый — когда ввод встраивается в текст шаблона перед компиляцией.

# УЯЗВИМО: ввод склеивается в ТЕКСТ шаблона, потом компилируется
from jinja2 import Template

def greet(name):
    # name пришёл от пользователя и стал частью исходника шаблона
    tpl_source = "<p>Привет, " + name + "!</p>"
    return Template(tpl_source).render()   # движок исполнит всё, что внутри {{ ... }}

Пока name — обычная строка, всё работает. Но если в name приходит конструкция шаблонного языка вида {{ выражение }}, движок не выведет её как текст — он её вычислит. Например, ввод {{ 7 * 7 }} вернёт 49 — это контрольный признак, по которому уязвимость обнаруживают на учебном стенде: число «оживает» вместо того, чтобы вывестись буквально. Дальше выражение можно усложнять и через свойства объектов добираться до опасных вызовов. Конкретные «оружейные» цепочки мы здесь не приводим — важен принцип: ввод оказался кодом.

Как это работает под капотом

Шаблонный язык — это полноценный мини-язык: у него есть выражения, доступ к атрибутам объектов, иногда вызовы. Когда движок компилирует шаблон, он превращает {{ ... }} в исполняемый код. Если внутри этих скобок оказался пользовательский ввод, исполнится пользовательский ввод. Через цепочку обращений к встроенным атрибутам объектов (родительские классы, глобальные функции окружения) атакующий способен дотянуться до выполнения команд. Поэтому даже «песочница» шаблонизатора — лишь дополнительный барьер, а не основная защита: основная защита в том, чтобы ввод вообще не попадал в текст шаблона.

Как защититься

1. Никогда не подставляйте ввод в текст шаблона

Шаблон должен быть статическим, написанным разработчиком. Пользовательские данные передавайте как значения переменных в контекст рендера — движок выведет их как обычный текст и не станет вычислять.

# БЕЗОПАСНО: шаблон фиксирован, ввод приходит как ДАННЫЕ
from jinja2 import Template

GREETING = Template("<p>Привет, {{ name }}!</p>")  # шаблон задан заранее

def greet(name):
    return GREETING.render(name=name)   # name подставится как значение, не как код

Вывод:

greet("Аня")          -> <p>Привет, Аня!</p>
greet("{{ 7 * 7 }}")  -> <p>Привет, {{ 7 * 7 }}!</p>   (выведено буквально, не вычислено)

2. Включайте автоэкранирование вывода

Чтобы значения переменных не приводили заодно к XSS, держите включённым автоэкранирование (autoescape) — движок сам превратит <, >, кавычки в HTML-сущности. Это защищает от внедрения HTML/JS через данные, но помните: автоэкранирование не спасает от SSTI, если вы склеили ввод в сам шаблон — там оно применяется уже после компиляции.

3. Песочница — дополнительный барьер

Если по требованиям пользователь всё же должен присылать шаблон (например, кастомные письма), используйте песочничный режим движка (sandboxed environment), который запрещает доступ к опасным атрибутам и вызовам. Это снижает риск, но не отменяет правила «доверенный шаблон лучше любой песочницы». Лучше дать пользователю ограниченный набор плейсхолдеров, а не полноценный шаблонный язык.

4. Разделяйте «движок шаблонов» и «движок логики»

Для пользовательского контента берите простые подстановочные форматы (только именованные плейсхолдеры без выражений), а полноценный шаблонизатор оставляйте для шаблонов, которые пишете вы. Если пользователю нужно влиять на вид письма или страницы, дайте ему набор готовых полей-плейсхолдеров (имя, дата, сумма), а не возможность писать выражения. Так пространство ввода ограничено данными и в принципе не пересекается с языком шаблона.

5. Как обнаружить SSTI

На собственном стенде уязвимость находят простым контрольным признаком: если в поле, которое потом попадает в страницу, подставить безобидное арифметическое выражение шаблонного синтаксиса, и в ответе вместо буквального текста появляется вычисленный результат — значит, ввод трактуется как код. В продакшене помогает обратное наблюдение: логируйте и расследуйте случаи, когда в пользовательских данных встречаются последовательности шаблонного синтаксиса (двойные фигурные скобки, управляющие конструкции движка) — для большинства полей это аномалия. Code review с конкретным вопросом «не склеивается ли где-то ввод в исходник шаблона?» закрывает большинство случаев ещё до релиза.

Итоги

  • SSTI возникает, когда ввод попадает в текст шаблона, а не в данные, и движок вычисляет его как выражение.
  • Признак на стенде: выражение вроде {{ 7 * 7 }} возвращает 49 вместо буквального вывода.
  • Главная защита — статический шаблон плюс передача ввода как значения переменной в контекст рендера.
  • Автоэкранирование защищает от XSS в данных, но не от SSTI при склейке; песочница движка — лишь дополнительный барьер.
  • Тестировать инъекции можно только на своих системах и в разрешённой лаборатории (УК РФ ст. 272).
Проверьте себя
1. В какой ситуации возникает Server-Side Template Injection?
AКогда шаблон хранится в базе данных, а не в файле
BКогда пользовательский ввод склеивается в сам текст шаблона перед его компиляцией
CКогда в шаблоне используется слишком много переменных
DКогда движок шаблонов работает на сервере, а не в браузере
2. Почему ввод {{ 7 * 7 }} в уязвимом шаблоне возвращает 49?
AДвижок случайно умножает числа в любом тексте
BКонструкция оказалась внутри текста шаблона, и движок вычислил её как выражение шаблонного языка
CЭто особенность кодировки UTF-8
DБраузер выполнил JavaScript в ответе
3. Какой подход устраняет SSTI надёжнее всего?
AВключить только автоэкранирование вывода
BДержать шаблон статическим и передавать пользовательские данные как значения переменных в контекст рендера
CЗапретить пользователям вводить цифры
DМинифицировать HTML перед отправкой