FlawOpen

flawopen.com/xss/Python

● CWE-79 · Высокий
Исследования · FlawOpen

XSS (межсайтовый скриптинг) в Python (Flask, Jinja2, Django)

Обёртка пользовательского ввода в Markup, mark_safe или f-строку, переданную в render_template_string, заставляет текст посетителя выполняться как JavaScript в браузере других пользователей, с их сессией (CWE-79). Где Flask, Jinja2 и Django экранируют за вас, четыре места, где они этого не делают, и как безопасно разрешить форматированный текст.

💡 Простыми словами (ELI5)

Представьте типографию с защищённой формой: текст в пустом поле печатается как обычный текст. Но если сотрудник до начала печати вставит записку клиента в лист с инструкциями принтеру, она может стать командой печати. Python f-string, переданная в render_template_string, делает именно это: вставляет ввод в исходный текст Jinja2 до того, как autoescape сможет обработать его как переменную. Markup и mark_safe похожи на штамп «HTML уже проверен», но ничего не проверяют. Оставляйте ввод в переменных шаблона и используйте autoescape либо применяйте format_html для небольшого фрагмента Django.

Ключевые понятия и термины

Jinja2 autoescape
Flask включает автоэкранирование Jinja2 для шаблонов .html, .htm, .xml и .xhtml и для render_template_string, поэтому {{ q }} превращается в <img ...>. У отдельного jinja2.Environment() автоэкранирование выключено, если не передать autoescape=select_autoescape().
Markup / |safe / mark_safe
Все три помечают строку как уже безопасный HTML, и шаблонизатор перестаёт её экранировать. Это обещания разработчика, а не санитайзеры: Markup(user_input) или mark_safe(user_input) публикует всё, что ввёл пользователь.
render_template_string(f"...")
f-строка вставляет значение в исходник шаблона ещё до того, как его разберёт Jinja2, поэтому автоэкранирование никогда не видит его как переменную. Та же строка — ещё и серверная инъекция шаблонов: значение вроде {{ 7*7 }} вычисляет Jinja2.
django.utils.html.format_html
Строит HTML как str.format, но сначала экранирует каждый аргумент через conditional_escape, а затем помечает результат безопасным. Это одобренный Django способ собрать небольшой фрагмент HTML в Python-коде вместо mark_safe(f"...").
nh3.clean()
Форматированному тексту нужен санитайзер, а не экранирование. nh3.clean() (ammonia на Rust) держит белый список тегов и атрибутов и удаляет <script>, обработчики событий и URL javascript:. Bleach устарел; nh3 — его поддерживаемый преемник.

Связи со стандартами

Пошаговый сценарий атаки

Шаг 1

Ввод приходит в запросе

Поле поиска, форма профиля или параметр ссылки передают на сервер q, bio или next. Жертве можно прислать подготовленную ссылку, либо значение сохраняется и показывается всем, кто открывает профиль.

Шаг 2

Значение обходит экранирование

f-строка помещает его в исходник шаблона, Markup() или mark_safe() объявляют его доверенным, или оно попадает в href, где экранирования < и > недостаточно. Безобидное тестовое значение <img src=x onerror=alert(1)> проходит без изменений.

Шаг 3

Браузер разбирает его как разметку

Ответ содержит <img src=x onerror=alert(1)> как настоящий элемент. Если ресурс изображения не загрузится, сработает onerror, а URL javascript: в ссылке выполняется при клике.

Шаг 4

Скрипт выполняется от имени сайта

В показанных сценариях Flask/Jinja и Django выведенная разметка выполняется в источнике приложения. Передавайте недоверенные значения в автоматически экранируемые позиции шаблона; разрешайте HTML только после очистки с учётом контекста.

Исходный код: Уязвимый vs Защищённый вариант

УЯЗВИМАЯ РЕАЛИЗАЦИЯ
# views.py: каждый обработчик ниже пропускает текст запроса на страницу как HTML
from django.utils.safestring import mark_safe
from flask import Flask, render_template_string, request
from markupsafe import Markup

app = Flask(__name__)


# f-строка собирает исходник шаблона, и Jinja2 никогда не экранирует q
@app.get("/search")
def search():
    q = request.args.get("q", "")
    return render_template_string(f"<h1>Results for {q}</h1>")


# Markup() сообщает Jinja2, что bio — доверенный HTML
@app.get("/profile")
def profile():
    bio = request.args.get("bio", "")
    return render_template_string("<div class='bio'>{{ bio }}</div>", bio=Markup(bio))


# mark_safe на f-строке публикует name как HTML
def greeting(name):
    return mark_safe(f"<p>Hello, {name}</p>")


# автоэкранирование не останавливает URL javascript: в href
@app.get("/continue")
def continue_link():
    url = request.args.get("next", "/")
    return render_template_string('<a href="{{ url }}">Continue</a>', url=url)
БЕЗОПАСНЫЙ ИСПРАВЛЕННЫЙ ВАРИАНТ
# views.py: значения остаются переменными шаблона; форматированный текст очищается
import nh3
from django.utils.html import format_html
from flask import Flask, render_template_string, request

app = Flask(__name__)


# q — переменная шаблона, автоэкранирование её кодирует
@app.get("/search")
def search():
    q = request.args.get("q", "")
    return render_template_string("<h1>Results for {{ q }}</h1>", q=q)


# nh3 применяет белый список тегов, прежде чем HTML получит доверие
@app.get("/profile")
def profile():
    bio = request.args.get("bio", "")
    return render_template_string("<div class='bio'>{{ bio|safe }}</div>", bio=nh3.clean(bio))


# format_html экранирует каждый аргумент, прежде чем пометить результат безопасным
def greeting(name):
    return format_html("<p>Hello, {}</p>", name)


# целью ссылки разрешены только пути своего сайта
@app.get("/continue")
def continue_link():
    url = request.args.get("next", "/")
    if not url.startswith("/") or url.startswith("//"):
        url = "/"
    return render_template_string('<a href="{{ url }}">Continue</a>', url=url)

Чек-лист по защите системы для инженеров

Источники