Редиректы в Django: разбор на примере рабочего проекта
Redirect в Django выглядит просто, пока не столкнёшься с потерей POST-данных, циклами и утечкой открытого редиректа. Разбираем, что происходит под капотом и где это ломается.

Открытый редирект в форме логина — одна из самых частых находок при аудите Django-приложений. Пользователь заходит по ссылке /login/?next=https://phishing.example, вводит пароль, и после успешной авторизации приложение честно отправляет его на чужой домен. Виноват не Django — виноват код, который слепо доверяет параметру next. Разберём, как устроены редиректы в Django 5.x, почему они иногда теряют данные и в каких местах приложение начинает вести себя не так, как ждёт разработчик.
Что на самом деле возвращает redirect
Функция django.shortcuts.redirect() не делает магии. Она возвращает объект HttpResponseRedirect (код 302) или HttpResponsePermanentRedirect (код 301, если передать permanent=True). Ответ несёт заголовок Location и пустое тело. Браузер, получив 302, делает новый GET-запрос по адресу из Location.
Ключевой момент, который упускают: редирект — это второй запрос. Всё, что было в теле первого (POST-данные, файлы), в него не переносится. Отсюда растёт половина проблем.
# Django 5.x
from django.shortcuts import redirect
from django.urls import reverse
def order_view(request):
if request.method == "POST":
order = Order.objects.create(user=request.user)
# редирект по имени URL с аргументом
return redirect("order_detail", pk=order.pk)
return render(request, "order_form.html")
Три способа вызова redirect(), которые часто путают:
redirect("order_detail", pk=5)— резолвит имя URL черезreverse();redirect("/orders/5/")— использует строку как есть, без резолва;redirect(order)— вызываетorder.get_absolute_url()у модели.
Если передать имя URL, которого нет в маршрутах, поднимется NoReverseMatch ещё на этапе формирования ответа — до того, как браузер что-то увидит. А вот строка проходит без проверок, поэтому опечатку в пути вы обнаружите только по 404.
Паттерн Post/Redirect/Get и почему без него никак
Если после обработки POST вернуть render() вместо редиректа, страница отрисуется по адресу формы. Пользователь нажимает F5 — браузер переспрашивает «отправить данные повторно?» и при подтверждении создаёт второй заказ. Это классическая двойная отправка.
Правило простое: успешный POST всегда заканчивается редиректом на GET. Ошибочный POST — рендерит форму заново с ошибками. Смешивать нельзя.
Именно поэтому в примере выше при успехе идёт redirect(), а при GET и при невалидной форме — render(). После редиректа обновление страницы безопасно: F5 просто повторяет GET деталей заказа.
Как передать сообщение через редирект
Тело ответа при редиректе пустое, значит контекст не передашь. Для этого есть django.contrib.messages — сообщение кладётся в сессию и переживает переход:
from django.contrib import messages
def order_view(request):
if request.method == "POST":
order = Order.objects.create(user=request.user)
messages.success(request, f"Заказ №{order.pk} создан")
return redirect("order_detail", pk=order.pk)
return render(request, "order_form.html")
На целевой странице сообщение достаётся из контекста messages в шаблоне. Для работы нужны включённые SessionMiddleware и MessageMiddleware — они есть в стандартном MIDDLEWARE по умолчанию.
Открытый редирект: где приложение ломается по-настоящему
Вернёмся к параметру next. Типичный уязвимый код:
# НЕБЕЗОПАСНО — не повторяйте
def login_view(request):
# ... проверка учётных данных ...
next_url = request.GET.get("next", "/")
return redirect(next_url) # редирект на любой адрес из запроса
Сюда можно подставить next=https://evil.example, и приложение отправит авторизованного пользователя на чужой сайт. Это уязвимость класса Open Redirect — её используют в фишинге, потому что ссылка ведёт на настоящий, доверенный домен.
Django даёт готовую защиту — url_has_allowed_host_and_scheme() из django.utils.http. Функция проверяет, что адрес ведёт на разрешённый хост и использует допустимую схему:
# Django 5.x — безопасный вариант
from django.utils.http import url_has_allowed_host_and_scheme
from django.shortcuts import redirect, resolve_url
def login_view(request):
# ... проверка учётных данных ...
next_url = request.GET.get("next", "")
if url_has_allowed_host_and_scheme(
url=next_url,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
):
return redirect(next_url)
return redirect("dashboard") # безопасный дефолт
Если вы используете встроенный LoginView из django.contrib.auth.views, эта проверка уже встроена и завязана на success_url_allowed_hosts. Проблема возникает именно там, где логин написан вручную.
Редиректы в CBV и на уровне URLconf
В классах-представлениях редирект оформляют иначе. Наследники FormView и CreateView используют атрибут success_url или метод get_success_url():
from django.views.generic import CreateView
from django.urls import reverse_lazy
class OrderCreateView(CreateView):
model = Order
fields = ["product", "quantity"]
# reverse_lazy, потому что URLconf ещё не загружен при импорте класса
success_url = reverse_lazy("order_list")
Обратите внимание на reverse_lazy, а не reverse. На уровне класса маршруты ещё не разрешены — ленивый резолв откладывает вычисление до момента реального использования. С обычным reverse здесь получите ImproperlyConfigured или NoReverseMatch при старте.
Для статических перенаправлений между URL есть RedirectView прямо в urls.py — код представления не нужен вовсе:
from django.urls import path
from django.views.generic import RedirectView
urlpatterns = [
# старый адрес ведёт на новый, 301 для SEO
path("old-blog/", RedirectView.as_view(
pattern_name="blog_index", permanent=True)),
]
301 против 302: разница, которая кусается
Код ответа выбирают не по вкусу. Разница влияет на кеширование и SEO.
| Признак | 301 (permanent) | 302 (temporary) |
|---|---|---|
| Как в Django | permanent=True | по умолчанию |
| Кеширование браузером | кешируется агрессивно | обычно не кешируется |
| Поисковики | переносят вес на новый URL | оставляют старый URL |
| Когда применять | адрес сменился навсегда | временный переход, логин, PRG |
Опасность 301 в том, что браузер запоминает его надолго. Если вы поставили permanent=True по ошибке и адрес на самом деле временный, у пользователей закешируется неправильное перенаправление, и «откатить» его на их стороне вы не сможете, пока не истечёт кеш. Для логина и PRG всегда 302.
Граничные случаи, о которых узнают в проде
- Цикл редиректов. Middleware, который редиректит неавторизованных на
/login/, а сам/login/тоже под защитой — браузер упрётся вERR_TOO_MANY_REDIRECTS. Исключайте страницу логина из проверки. - Потеря query string.
redirect("order_list")не сохраняет?page=3из исходного запроса. Если параметры нужны, собирайте адрес вручную черезreverse()плюсrequest.GET.urlencode(). - APPEND_SLASH. При включённом
APPEND_SLASH(значение по умолчанию —True) запрос на/ordersбез слеша Django редиректит на/orders/. Но это работает только для GET: POST на адрес без слеша даст 404, потому что редиректить POST с сохранением тела нельзя. Частая причина «форма не отправляется». - Редирект и HTMX/AJAX. Браузерный fetch следует за 302 прозрачно, и ваш JS получит уже тело целевой страницы, а не код перенаправления. Если фронтенду нужно узнать об редиректе, отдавайте не 302, а JSON с адресом и делайте переход на клиенте.
Что проверить в своём коде дальше
Пройдитесь по проекту с тремя вопросами. Первый: каждый ли обработчик POST заканчивается редиректом при успехе — или где-то остался render(), дающий двойную отправку. Второй: все ли места, где вы читаете next, redirect_to или похожие параметры из запроса, прогоняются через url_has_allowed_host_and_scheme(). Третий: нет ли permanent=True там, где адрес на самом деле временный.
Для регрессии напишите тест, который дёргает логин с next=https://evil.example и проверяет, что редирект ведёт на ваш домен, а не на чужой. Инвалидация закешированных 301 у уже зашедших пользователей — отдельная тема, здесь её не разбираем. Точное поведение APPEND_SLASH и список middleware сверяйте с документацией вашей версии Django, если проект не на 5.x.
Разбор научной статьи: Сложный текст — понятным языком, с терминами
Спросить за 12 ₽Источники: Django docs: django.shortcuts redirect(), Django docs: url_has_allowed_host_and_scheme
Частые вопросы
У меня Django 4.2, функция url_has_allowed_host_and_scheme работает так же?
Почему после редиректа теряется сообщение об ошибке из формы?
Не сработало: получаю NoReverseMatch при redirect по имени URL
Грозит ли открытый редирект чем-то серьёзным, если сайт небольшой?
Можно ли редиректить POST-запрос, чтобы не потерять данные формы?
Почему POST на URL без слеша даёт 404, а GET работает?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.