skip to content

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?

level: middleimportance: should knowfreq 40%

answer

  1. open redirect
  2. django.utils.http helper
  3. allowed_hosts from get_host()
  4. require_https from is_secure()
  5. scheme-relative and triple-slash tricks

basics

~10 s

Check 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 lines
python
from 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

for a junior

Never redirect straight to a ?next= value; check it with url_has_allowed_host_and_scheme() and use a fixed fallback.

for a middle

Explain the arguments, why get_host() and is_secure() are passed, and which URL shapes the helper rejects, including '//' and backslash tricks.

for a senior

Point out the exact-match host semantics, the ftp scheme HttpResponseRedirect still allows, and the need for tests around an internal helper.

for a principal

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