In a custom Django view, how do you safely redirect to a user-supplied ?next= URL, and what does url_has_allowed_host_and_scheme() reject?
answer
- open redirect
- django.utils.http helper
- allowed_hosts from get_host()
- require_https from is_secure()
- scheme-relative and triple-slash tricks
basics
~10 sCheck the value with django.utils.http.url_has_allowed_host_and_scheme(url, allowed_hosts={request.get_host()}, require_https=request.is_secure()) and fall back to a fixed URL when it returns False. It rejects foreign hosts, non-HTTP schemes and browser-parsing tricks.
solid answer
~40 s`HttpResponseRedirect` only refuses schemes outside `http`, `https` and `ftp`, so `redirect(request.GET['next'])` is an open redirect. Validate first with `url_has_allowed_host_and_scheme()` from `django.utils.http`, passing `allowed_hosts={request.get_host()}` (plus any trusted hosts) and `require_https=request.is_secure()`, and use a safe default when it returns `False`. It returns `False` for an empty value, for a host not in the set (exact comparison, port included), for schemes other than `http`/`https` (`https` only when `require_https`), and for tricks browsers resolve to another host: `//evil.example`, `///evil.example`, `http:///evil.example`, backslash variants and leading control characters. Relative paths like `/orders/42/` pass. It replaced `is_safe_url()`, renamed in 3.0 and removed in 4.0; Django's own `set_language` view uses it the same way.
code
python · 18 linesfrom django.shortcuts import redirect
from django.utils.http import url_has_allowed_host_and_scheme
from django.views.decorators.http import require_POST
@require_POST
def save_preferences(request):
request.user.profile.theme = request.POST.get("theme", "light")
request.user.profile.save()
next_url = request.POST.get("next", "")
if not url_has_allowed_host_and_scheme(
url=next_url,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
):
next_url = "/"
return redirect(next_url)go deeper
Never redirect straight to a ?next= value; check it with url_has_allowed_host_and_scheme() and use a fixed fallback.
Explain the arguments, why get_host() and is_secure() are passed, and which URL shapes the helper rejects, including '//' and backslash tricks.
Point out the exact-match host semantics, the ftp scheme HttpResponseRedirect still allows, and the need for tests around an internal helper.
Centralise redirect validation in one utility or mixin so every team inherits the same allow-list and tests.
## The problem: an open redirect Many views accept a return address: "save your settings, then go back to where you were" via `?next=/some/page/`. If the view redirects to whatever it receives, an attacker can send `https://www.example.com/settings/save/?next=https://evil.example/login` and the trusted domain becomes a springboard for phishing. That is an **open redirect**. Django's redirect responses do not stop it. `HttpResponseRedirect` (and `redirect()`) only check the **scheme** against `allowed_schemes = ['http', 'https', 'ftp']`, raising `DisallowedRedirect` for things like `javascript:`. They never look at the host. ## The helper **`url_has_allowed_host_and_scheme(url, allowed_hosts, require_https=False)`** lives in `django.utils.http`. It is the function Django's own code uses for this job: the auth views' redirect mixin and `django.views.i18n.set_language` both call it. It is not covered by the reference documentation, only by release notes, so treat it as a helper whose behaviour you pin with your own tests. It was introduced in **Django 3.0** as the new name of `is_safe_url()`, and the old name was **removed in 4.0**. Arguments: - **`allowed_hosts`**: a set of host strings. Pass `{request.get_host()}`, which is itself validated against `ALLOWED_HOSTS`, plus any other domains you trust. A single string is accepted and wrapped in a set; `None` means no host is allowed. - **`require_https`**: pass `request.is_secure()` so an HTTPS page never redirects to plain HTTP. ## What it rejects and what it accepts | Input | Result | Reason | |---|---|---| | `/orders/42/` | accepted | relative path, no host | | `https://www.example.com/x` with that host allowed | accepted | allowed host, allowed scheme | | `https://evil.example/` | rejected | host not in the set | | `//evil.example/` | rejected | scheme-relative URL, host is checked as if `http` | | `///evil.example` | rejected | three leading slashes, browsers treat it as absolute | | `http:///evil.example` | rejected | scheme without a host, some browsers read the path as the host | | `\\evil.example` or `/\evil.example` | rejected | backslashes are checked again as forward slashes | | `javascript:alert(1)` | rejected | scheme is not `http` or `https` | | empty string or `None` | rejected | always `False` on an empty url | | `http://www.example.com/` when `require_https=True` | rejected | only `https` counts | Two details trip people up: 1. The host comparison is **exact on the network location**, including a port, with no leading-dot wildcard. `ALLOWED_HOSTS`-style patterns like `'.example.com'` do not work here; list each host. 2. A `True` result does not make a URL fully safe to emit; the function's own docstring recommends `iri_to_uri()` on the path of untrusted URLs. ## The pattern 1. Read the candidate from `request.POST` or `request.GET`. 2. Validate with the helper, using `get_host()` and `is_secure()`. 3. Redirect to it if valid, otherwise to a fixed fallback such as `'/'` or a named URL. Keep the fallback fixed. Falling back to the `Referer` header without validating it, as some hand-rolled views do, reintroduces the problem; Django's `set_language` view validates the referer with the same helper before using it. ## Hand-rolled checks that fail Interviewers like to show a homemade validator and ask what is wrong with it: - **`next.startswith('/')`** accepts `//evil.example/`, which browsers treat as a scheme-relative absolute URL. - **`urlsplit(next).netloc == ''`** accepts `/\evil.example` and `http:///evil.example`, which some browsers resolve to `evil.example`. - **A regex allow-list on the domain** usually forgets ports, case, or the userinfo part of a URL such as `https://[email protected]/`. - **Checking against the `Referer`** trusts another client-controlled header. Each one reinvents part of what the helper already handles, and each misses at least one browser quirk. ## Why it belongs with host validation The helper and `ALLOWED_HOSTS` guard the same trust boundary from two sides. `ALLOWED_HOSTS` decides which host the *request* may claim; `url_has_allowed_host_and_scheme()` decides which host a *response* may send the browser to. Passing `{request.get_host()}` ties the second to the first.
- Why pass request.get_host() instead of settings.ALLOWED_HOSTS as allowed_hosts?The helper compares network locations exactly, with no leading-dot or `'*'` semantics, so `ALLOWED_HOSTS` patterns would not behave as intended. `request.get_host()` is the concrete host already validated against `ALLOWED_HOSTS`, including its port, which is exactly what a same-site redirect needs; add other trusted hosts explicitly.
- Does redirect() in Django ever block an unsafe URL on its own?Only by scheme. `HttpResponseRedirect` raises `DisallowedRedirect` for schemes outside `http`, `https` and `ftp`, such as `javascript:`. A redirect to `https://evil.example/` passes, so host validation is always the view's job.
saying these in an interview costs you the question
- Django's redirect() already blocks redirects to other domains
- Checking that next starts with '/' is enough to prevent open redirects
- Passing ALLOWED_HOSTS patterns like '.example.com' as allowed_hosts works the same as in settings
- is_safe_url() is the current name of the helper
- Falling back to the unvalidated Referer header is a safe default