flawopen.com/xss/Python
XSS (межсайтовый скриптинг) в Python (Flask, Jinja2, Django)
Обёртка пользовательского ввода в Markup, mark_safe или f-строку, переданную в render_template_string, заставляет текст посетителя выполняться как JavaScript в браузере других пользователей, с их сессией (CWE-79). Где Flask, Jinja2 и Django экранируют за вас, четыре места, где они этого не делают, и как безопасно разрешить форматированный текст.
Представьте типографию с защищённой формой: текст в пустом поле печатается как обычный текст. Но если сотрудник до начала печати вставит записку клиента в лист с инструкциями принтеру, она может стать командой печати. 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>, обработчики событий и URLjavascript:. Bleach устарел; nh3 — его поддерживаемый преемник.
Связи со стандартами
- Ссылка CWE для слабого места, описанного в этом руководстве: CWE-79
- OWASP Top 10:2025 — A05:2025 Injection
Пошаговый сценарий атаки
Ввод приходит в запросе
Поле поиска, форма профиля или параметр ссылки передают на сервер q, bio или next. Жертве можно прислать подготовленную ссылку, либо значение сохраняется и показывается всем, кто открывает профиль.
Значение обходит экранирование
f-строка помещает его в исходник шаблона, Markup() или mark_safe() объявляют его доверенным, или оно попадает в href, где экранирования < и > недостаточно. Безобидное тестовое значение <img src=x onerror=alert(1)> проходит без изменений.
Браузер разбирает его как разметку
Ответ содержит <img src=x onerror=alert(1)> как настоящий элемент. Если ресурс изображения не загрузится, сработает onerror, а URL javascript: в ссылке выполняется при клике.
Скрипт выполняется от имени сайта
В показанных сценариях 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)
Чек-лист по защите системы для инженеров
- ✓Роняйте CI на Bandit B703/B308 (
mark_safe) и B704 (Markupдля нелитералов), а также на любомrender_template_string, первый аргумент которого — f-строка,%или результат.format(). - ✓Передавайте значения в шаблоны как переменные и позвольте автоэкранированию сделать своё дело; для отдельного Jinja2 создавайте
Environment(autoescape=select_autoescape()). - ✓Используйте
format_html()вместоmark_safe()для небольших фрагментов иnh3.clean()для любого HTML от пользователей. - ✓Проверяйте каждый URL, управляемый пользователем, до того как он попадёт в
hrefилиsrc: принимайте только пути своего сайта, начинающиеся с одного/, или явный белый список http/https. - ✓Отправляйте Content-Security-Policy с
script-srcна основе nonce и без'unsafe-inline', а cookie сессии помечайтеHttpOnlyиSameSite=Lax.
Источники
- CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
- Flask — Security Considerations: Cross-Site Scripting
- Django — Security: Cross site scripting (XSS) protection
- Django — format_html
- nh3 — Python bindings to the ammonia HTML sanitizer
- OWASP Cross Site Scripting Prevention Cheat Sheet
- nh3 — Python bindings to the ammonia HTML sanitizer