Перейти к содержанию

192 уроков, 14 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 19 из 20 Средний 40 мин 130 XP

Безопасность Flask: CSRF, экранирование и секреты

Девятнадцатый урок расширения Flask: живая демонстрация автоэкранирования Jinja2, дыра через |safe своими руками, CSRF-токены словами, секретный ключ и подпись сессии — и чек-лист защиты готового сайта.

Редакция Питоники

Твой сайт теперь умеет почти всё: формы из урока про формы, вход из урока про аутентификацию, API с тестами. Значит, настало время неприятной части профессии: на сайт будут пытаться влить опасный ввод и подделывать чужие запросы. Хорошая новость курса: три главные защиты ставятся почти бесплатно, и две из них ты уже видел мельком. Сейчас соберём их в систему — и каждый запускай, это живые демонстрации.

Экранирование: первая линия обороны

Сценарий атаки XSS (cross-site scripting): злоумышленник оставляет в отзыве не текст, а HTML со скриптом. Если страницу вывести как есть — скрипт выполнится в браузере каждого посетителя и сможет, например, отправить злоумышленнику их cookies. Защита — экранирование: символы разметки превращаются в безобидные HTML-сущности, и браузер показывает ввод как текст. Смотри на живой пример:

Опасный ввод превращается в текст (запустите)
from jinja2 import Environment

# так настраивает шаблонизатор Flask для страниц .html
env = Environment(autoescape=True)

comment = "<img src=x onerror=alert(1)>"

safe_view = env.from_string("Отзыв: {{ comment }}")
print(safe_view.render(comment=comment))
Вывод
Отзыв: &lt;img src=x onerror=alert(1)&gt;
Угловые скобки стали &lt; и &gt;: браузер покажет этот текст как есть, а тег выполнять не будет. Ровно это делает Jinja2 внутри Flask перед выводом каждой переменной.

Во Flask эта защита включена по умолчанию для шаблонов с расширением .html — render_template экранирует каждую выводимую переменную автоматически. В уроке про шаблоны мы уже видели, что голый Template без автоэкранирования пропускает скрипт насквозь — потому песочница и использовала флаг autoescape=True. Итого экранирование достаётся тебе бесплатно; платой за него является один-единственный соблазн, который стоит разобрать руками:

Заметь принцип, на котором держится экранирование: оно не пытается угадать, опасен ли ввод. Никакие чёрные списки здесь не выживают — запрещённое слово script легко разнести на куски и склеить в браузере, а запретить все символы разметки — значит сломать честные отзывы про «С++ и шаблоны <T>». Экранирование проще и хитрее: оно обезвреживает всё подряд и полагается на единственное исключение, которое контролируешь ты, — фильтр |safe для собственной разметки. Одна политика без исключений на данных пользователей всегда сильнее десятка умных фильтров.

Дыра |safe своими руками (запустите)
from jinja2 import Environment

env = Environment(autoescape=True)
comment = "<script>document.location='http://evil.example/steal'</script>"

# |safe выключает экранирование для этой переменной
hole = env.from_string("Отзыв: {{ comment | safe }}")
print(hole.render(comment=comment))

# без |safe тот же ввод безобиден
correct = env.from_string("Отзыв: {{ comment }}")
print(correct.render(comment=comment))
Вывод
Отзыв: <script>document.location='http://evil.example/steal'</script>
Отзыв: &lt;script&gt;document.location=&#39;http://evil.example/steal&#39;&lt;/script&gt;
Первая строка - готовый скрипт: попадёт на страницу без изменений и выполнится у каждого посетителя. Вторая - тот же ввод, обезвреженный экранированием.

CSRF: подделка запроса словами

Вторая атака бьёт не по твоему коду, а по браузеру пользователя. Механика CSRF (cross-site request forgery, подделка межсайтового запроса) такая: посетитель залогинен на твоём сайте, и его браузер носит cookie сессии. Он открывает в соседней вкладке вредительскую страницу, а та тихо отправляет POST прямо на твой сервер — например, на маршрут перевода денег. Браузер честно приложит cookie залогиненного пользователя, и сервер выполнит запрос, не сумев отличить его от честного:

Так выглядит страница атакующего (не запускайте — и не пишите такую)
<!-- чужой сайт: форма отправляется сама, как только страница открылась -->
<body onload="document.forms[0].submit()">
  <form method="post" action="https://bank.example/transfer">
    <input type="hidden" name="amount" value="10000">
    <input type="hidden" name="to" value="attacker">
  </form>
</body>
Разбор приёма, а не инструкция: пользователь ничего не нажимал, а POST на твой маршрут уже уехал вместе с его cookie. Защита строится на том, что чужой сайт не может прочитать данные твоего сайта - этим и пользуется токен.

Защита — CSRF-токен. При отдаче формы сервер кладёт в неё скрытое поле со случайной строкой и запоминает ту же строку в сессии. Приём формы запрещён, если токен из формы не совпал с токеном из сессии. Схема проверки:

Форма с токеном и проверка на сервере (схема)
<!-- шаблон формы -->
<form method="post" action="/transfer">
  <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
  <input name="amount">
  <button>Перевести</button>
</form>
csrf_token() - случайная строка из сессии посетителя. У Flask из коробки такой функции нет, её дают формы-расширения: Flask-WTF строит токен и проверяет его сам.
Серверная сторона проверки (схема)
from flask import abort, request, session

@app.route("/transfer", methods=["POST"])
def transfer():
    form_token = request.form.get("csrf_token", "")
    if form_token != session.get("csrf_token"):
        abort(400, description="CSRF-проверка не пройдена")
    # токен совпал - запрос честный, выполняем перевод
    ...
Суть механизма без расширений: сравнили form с session, не сошлось - отказ. На практике это делает Flask-WTF: поле form.csrf_token и валидация в одну строку.

Почему это работает: вредительская страница может отправить на твой сервер запрос, но прочитать ответ или содержимое твоих страниц ей браузер не даст — действует правило одинакового происхождения (same-origin policy). Токен живёт в сессии твоего сайта, значит чужая страница его узнать не может и угадать не сумеет: случайная строка длиной в десятки символов. Запрос без токена до перевода не доходит.

Попутно — о мерах, которые защитой только притворяются. Скрытые поля формы без токена не защищают ничем: вредительская страница отправляет их так же охотно, как пользователь. Проверка заголовка Referer выглядит заманчиво, но ненадёжна: браузеры и расширения режут его по разным причинам, а некоторые запросы уходят вовсе без него — отсюда либо дыры, либо ложные отказы честным пользователям. Рабочая схема ровно одна: случайный токен, привязанный к сессии, и обязательная проверка на сервере.

Секретный ключ и подпись сессии

Третий замок охраняет cookie-сессию из урока про сессии. Пользователь мог бы не ждать твоей ошибки и отредактировать cookie руками: записать себе session['is_admin'] = true. Поэтому Flask cookie не хранит, а подписывает: содержимое сессии упакуется, подпишется SECRET_KEY и уйдёт в браузер в этом виде. Подпись проверить может только сервер с тем же ключом. Что бывает без ключа, ты видел, если забывал его настроить:

Session без SECRET_KEY — RuntimeError
RuntimeError: The session is unavailable because no secret
key was set. Set the secret_key on the application to
something unique and secret.
Flask отказывается работать с сессией без ключа - это защита от ложного чувства безопасности. Ключ задаётся одной строкой app.secret_key = ..., но где её писать - вопрос серьезнее, об этом ниже.

Механику подписи можно пощупать без сервера: библиотека itsdangerous, на которой Flask подписывает сессии, ставится вместе с ним. Подпишем данные, прочитаем честный токен и попробуем подсунуть подделку:

Здесь же уместно точное слово о природе подписи: подпись — это не шифрование. Содержимое сессии лежит в cookie почти открытым текстом, любой любопытный может его раскодировать и прочитать — например, что там user_id 7. Незаметно изменить это содержимое нельзя, а вот читать — можно. Отсюда практическое правило: в сессии хранят указатели (идентификатор пользователя, настройки интерфейса), а секреты — номера карт, токены чужих сервисов — в базе на сервере, доставая их по этому идентификатору.

Подпись токена и раскрытие подделки (запустите)
from itsdangerous import BadData, URLSafeSerializer

# ключ знает только сервер - им подписывают сессии и токены
signer = URLSafeSerializer("k1uch-s-adressom-peresolju")

token = signer.dumps({"user_id": 7})
print("Токен выдан, символов:", len(token))
print("Честный токен читается:", signer.loads(token))

broken = token + "x"  # злоумышленник дописал символ в чужой токен
try:
    signer.loads(broken)
    print("Подделка принята")
except BadData:
    print("Подделка отклонена: подпись не сходится")
Вывод
Токен выдан, символов: 46
Честный токен читается: {'user_id': 7}
Подделка отклонена: подпись не сходится
Данные в токене читаются (это не шифрование), но изменить их незаметно нельзя: любая правка ломает подпись. Ровно так Flask защищает содержимое session-куки.

Чек-лист безопасности Flask-сайта

Собираем урок в таблицу — перед деплоем прогоняйся по ней глазами:

УгрозаЗамокКак ставится
XSS: скрипт в отзывеавтоэкранирование Jinja2включено во Flask по умолчанию для .html - не отключай
XSS через |safeдисциплина фильтра safe|safe только для разметки, собранной твоим кодом
CSRF: чужой сайт шлёт POSTтокен в форме и проверкаFlask-WTF: поле csrf_token и валидация формы
Подделка cookie-сессииподпись SECRET_KEYдлинный случайный ключ из переменной окружения
Перехват сессиифлаги cookieSESSION_COOKIE_SECURE=True, HttpOnly, короткий срок жизни
Настройки cookie сессии для продакшена
app.config.update(
    SESSION_COOKIE_SECURE=True,        # куки уезжает только по HTTPS
    SESSION_COOKIE_HTTPONLY=True,      # JavaScript не читает куку
    PERMANENT_SESSION_LIFETIME=1800,   # постоянная сессия живёт 30 минут
)
HttpOnly у сессионной куки Flask включён и так, а SESSION_COOKIE_SECURE и срок жизни стоит задать самому. Это последние три строки, которые стоит дописать перед выходом в продакшен.

Итоги

Три замка стоят: экранирование обезвреживает опасный ввод прямо в шаблоне, CSRF-токен не пускает чужие сайты к действиям твоих пользователей, подписанная сессия не даёт подделывать cookie. Ни один из них не потребовал библиотек сверх уже знакомых — безопасность во Flask по большей части состоит из привычек: не отключать экранирование, не принимать POST без токена, не показывать ключ.

Остался последний урок расширения: соберём финальный проект — API заметок с фабрикой, блюпринтом и pytest-набором, где всё выученное в уроках 11–19 работает в одном приложении. Карта курса — самоучитель Flask.

Экранирование превращает опасный ввод в безобидный текст, а один фильтр |safe отменяет эту защиту для конкретной переменной.

Что выведет код?

Сначала предскажи ответ в голове — это главный навык программиста.

def escape(s):
    return (s.replace("&", "&amp;")
             .replace("<", "&lt;")
             .replace(">", "&gt;"))

print(escape("<b>жирный</b>"))
def render(value, safe=False):
    if safe:
        return value
    return value.replace("<", "&lt;").replace(">", "&gt;")

comment = "<script>x</script>"
print(render(comment))
print(render(comment, safe=True))
def sign(payload, key):
    return payload + "." + str(len(payload) * len(key))

def verify(token, key):
    payload, sig = token.rsplit(".", 1)
    return sign(payload, key) == token

token = sign("user7", "k1uch")
broken = token + "x"
print(verify(token, "k1uch"), verify(broken, "k1uch"))
Проверь себя
0 / 6

1. Что делает автоэкранирование Jinja2 во Flask?

2. Когда фильтр |safe допустим?

3. Почему CSRF-токен останавливает атаку с чужой страницы?

4. Зачем сессии нужен SECRET_KEY?

5. Где правильнее хранить SECRET_KEY рабочего сайта?

6. Почему разрушающие действия нельзя вешать на GET-ссылку?

Карточки терминов
Запомнено: 0 / 7
Практика

Собери мини-шаблонизатор отзыва: функция render_comment(text, allow_html=False) при allow_html=False возвращает текст с заменой & на &amp;, затем < на &lt; и > на &gt;, а при True — сырой текст. Напечатай оба варианта для ввода «<b>отзыв</b>». Порядок замен важен — сначала амперсанд.

practice.py
Вопросы и ответы по уроку

Включено ли экранирование HTML во Flask по умолчанию?

Да: для шаблонов с расширением .html render_template работает с автоэкранированием — каждая выводимая переменная превращает <, > и кавычки в HTML-сущности, и скрипт из пользовательского ввода становится безобидным текстом. Отключается защита только фильтром |safe для конкретной переменной — используйте его исключительно для разметки, собранной собственным кодом.

Что такое CSRF и как защититься во Flask?

CSRF — подделка межсайтового запроса: вредительская страница отправляет POST на ваш сайт от имени залогиненного пользователя, и браузер прикладывает его cookie. Защита — случайный токен, который сервер кладёт в форму и в сессию и требует совпадения при приёме. Во Flask это принято делать расширением Flask-WTF: поле form.csrf_token и автоматическая валидация. И не вешайте изменяющие данные действия на GET-ссылки.

Зачем Flask SECRET_KEY и где его хранить?

Ключем подписывается cookie-сессия: содержимое пользователь может прочитать, но изменить незаметно — подпись сойдётся только у сервера с тем же ключом. Без ключа сессия недоступна с ошибкой RuntimeError. Рабочий ключ держите в переменной окружения (app.secret_key = os.environ["SECRET_KEY"]) и генерируйте через python -c "import secrets; print(secrets.token_hex())"; ключ в репозитории считается скомпрометированным.

Достаточно ли этих трёх мер для безопасности сайта?

Они закрывают три самые частые дыры веб-форм: XSS, CSRF и подделку сессии. Дальше список продолжают хеширование паролей (оно было в уроке про аутентификацию), параметризованные запросы к базе против SQL-инъекций, HTTPS и флаги cookie из чек-листа урока. Безопасность — это не одна кнопка, а привычка проверять каждое новое место, где данные пользователя доходят до сервера или шаблона.

Понравился урок? Сошлитесь на него

«Экранирование превращает опасный ввод в безобидный текст, а один фильтр |safe отменяет эту защиту для конкретной переменной.»

Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.

Проверьте знания по Flask

В челлендже — 20 задач по Flask, по 2 из каждого урока этого раздела. Формат: фрагмент кода и четыре варианта — что напечатает. После ответа — вердикт и объяснение со ссылкой на урок-источник.

Тест по Flask: 20 задач