Prefer Django's LoginView, which checks next itself; add other hosts with success_url_allowed_hosts. In your own views, redirect only when url_has_allowed_host_and_scheme(next, allowed_hosts={...}, require_https=request.is_secure()) is true, and otherwise to a fixed page. Never trust startswith('/') or a host substring test.
- Find every redirect built from request data: redirect(), HttpResponseRedirect and HttpResponsePermanentRedirect with request.GET, request.POST, cookies or the Referer header, and next or return_to parameters in templates.
- For sign-in, use django.contrib.auth.views.LoginView: it validates next against request.get_host() plus success_url_allowed_hosts and falls back to LOGIN_REDIRECT_URL.
- In custom views, pass the value to url_has_allowed_host_and_scheme() with an explicit set of allowed hosts and require_https=request.is_secure(), and redirect to a fixed page when it returns False.
- Keep the allowlist to exact host names you control; never derive it from the Host header without ALLOWED_HOSTS validation.
- Add regression tests that sign in with this card's payloads and resolve the Location header with a WHATWG URL parser (ada-url here) before comparing origins.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
def redirect_after_login(request, next_url: str | None):
return redirect(next_url or "/account")With IMPL=vulnerable, 6 of 16 tests fail (6 exploit tests).
The fix
ALLOWED_HOSTS = {"app.example.test", "docs.example.test"}
def redirect_after_login(request, next_url: str | None):
if next_url and url_has_allowed_host_and_scheme(
next_url, allowed_hosts=ALLOWED_HOSTS, require_https=request.is_secure()
):
return HttpResponseRedirect(next_url)
return HttpResponseRedirect("/account")With IMPL=fixed, 16 of 16 tests pass.
A fix that looks safe but is not
def redirect_after_login(request, next_url: str | None):
return HttpResponseRedirect(checked_target(next_url))
def checked_target(next_url: str | None) -> str:
if not next_url:
return "/account"
# A path on this site...
if next_url.startswith("/"):
return next_url
# ...or a full link back to one of our sites, app.example.test or docs.example.test.
if "example.test" in (urlsplit(next_url).hostname or ""):
return next_url
return "/account"With IMPL=wrong, 3 of 16 tests fail (3 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
# Each value arrives in a link to the real sign-in page, such as /login?next=%2F%2Fevil.example%2F.
# After a genuine sign-in the browser must still be on this site or the allowlisted docs site.
ATTACKS = {
"an absolute URL to another site": "https://evil.example/",
"a scheme-relative URL": "//evil.example/",
"three slashes": "///evil.example/",
"a backslash after the slash": "/\\evil.example/",
"two backslashes": "\\\\evil.example/",
"our host name as a user name": "https://app.example.test@evil.example/",
"a look-alike host that contains an allowed name": "https://docs.example.test.evil.example/",
"a backslash before the @": "https://evil.example\\@docs.example.test/",
"encoded slashes": "/%2F%2Fevil.example/",
"a javascript: URL": "javascript:alert(document.domain)",
}
@pytest.mark.parametrize("next_url", list(ATTACKS.values()), ids=list(ATTACKS))
def test_exploit_lands_on_an_allowed_site(client, alice, next_url):
response = sign_in_with_next(client, alice, next_url)
url = landing(response)
location = response.headers["Location"]
assert url is not None and url.origin in ALLOWED_ORIGINS, (
f"next={next_url} sent the browser to {location} (origin {url.origin if url else 'none'})"
)- startswith('/') accepts //evil.example/ and ///evil.example/, which browsers read as links to evil.example; Django sends both unchanged in the Location header. That is half of this card's wrong implementation.
- A host test with in or endswith accepts docs.example.test.evil.example and every subdomain of your domain, including ones someone else can take over. Compare whole host names against a set.
- url_has_allowed_host_and_scheme() is the check LoginView uses, but Django's 3.0 release notes call it a private internal utility, renamed from is_safe_url(), which 4.0 removed. Pin your Django version, keep the regression tests, and prefer LoginView where you can.
- Its allowed_hosts must include your own host for absolute URLs to your site; relative paths pass without it. require_https=True rejects http:// URLs, which is what you want on an HTTPS site.
- HttpResponseRedirect percent-encodes characters such as backslashes (iri_to_uri) and raises DisallowedRedirect, which Django answers with 400, for schemes other than http, https and ftp. That stopped this card's backslash and javascript: payloads by accident; it is not an allowlist of hosts.
- Browsers refuse javascript: in a Location header, but the same unchecked value in an href or in window.location runs as script. Validate next before you render it into a link too.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs open-redirect/python-django does this for you).
- Against your own app, sign in through /login?next=%2F%2Fevil.example%2F and /login?next=https%3A%2F%2Fapp.example.test%40evil.example%2F, resolve each Location header against the sign-in page and expect your own origin.
- grep for redirect( and HttpResponseRedirect( with request data; Semgrep's Django open-redirect rule is file-local and found nothing here, so the regression tests are the control.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | python.django.security.injection.open-redirect.open-redirect | No findings on this card, for any implementation: the request value is read in views.py and redirected in accounts/impl/*.py. On a one-file probe it flagged HttpResponseRedirect(next_url) with next_url from request.POST, with or without a startswith('/') check, skipped the version guarded by url_has_allowed_host_and_scheme(), and missed redirect(request.POST.get('next') or '/account'). Its message still recommends is_safe_url(), which Django 4.0 removed.Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/<id> accounts config (Ran 1 rule) |
| Semgrep | p/django, p/python, p/security-audit, p/owasp-top-ten | No findings on any implementation.Checked: Semgrep 1.180.0 (CE); on accounts and config |
| CodeQL | py/url-redirection | From the sources: Django.qll models HttpResponseRedirect and redirect() as redirect sinks and request.POST as remote input, and Django.model.yml declares url_has_allowed_host_and_scheme() a barrier guard, so the fixed code is recognised as checked. A startswith('/') test is not a barrier, so the wrong implementation should still be reported if the flow through importlib.import_module is followed; that was not tested.Checked: github/codeql main at b5b8165; not executed |
| Bandit | n/aNo open-redirect plugin | No findings. Bandit has no check for redirects or CWE-601.Checked: Bandit 1.9.4; bandit -r accounts config |
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | Django 6.1.2 (django.contrib.auth sign-in) |
| django | 6.1.2 |
| pytest (test only) | 9.1.1 |
| pytest-django (test only) | 4.14.0 |
| ada-url (test only: WHATWG URL parser) | 4.0.0 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the open-redirect/python-djangofolder.
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest
# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest
# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytestEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs open-redirect/python-django runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- python CPython 3.14.8
- django 6.1.2
- pytest 9.1.1
- pytest-django 4.14.0
- adaUrl 4.0.0
- semgrep 1.180.0
- bandit 1.9.4
- Matrix
- fixed: 16 of 16 tests pass; vulnerable: 6 of 16 tests fail (6 exploit tests); wrong: 3 of 16 tests fail (3 exploit tests)
- Test results
- fixed 16/16 pass; vulnerable 6/16 fail (6 exploit tests: the absolute URL, //, ///, our host as a user name, the look-alike host and javascript:); wrong 3/16 fail (3 exploit tests: //, /// and the look-alike host), offline after install
- Dependency freshness
- Django 6.1.2 was published on 2026-10-06 and is taken at four days old because it is a security release (CVE-2026-77050, CVE-2026-84429, CVE-2026-87890); pytest-django 4.14.0 dates from 2026-08-10. ada-url 4.0.0, cffi 2.1.1 and pycparser 3.0 are test-only and match the Flask card's pins. iniconfig is pinned at 2.3.0 rather than 2.3.1 (2026-10-06), which was four days old and is not a security release. OSV reported no known vulnerabilities for any pin. Rerun on CPython 3.14.8, the 30 September 2026 expedited security release.
- Notes
- Django's HttpResponseRedirect sent /\evil.example/ as /%5Cevil.example/, \\evil.example/ as %5C%5Cevil.example/ and https://evil.example\@docs.example.test/ as https://evil.example%5C@docs.example.test/, which a browser follows to docs.example.test, so the vulnerable code stays on allowed sites for those three. javascript:alert(document.domain) raised DisallowedRedirect and became a 400, which fails the test because no redirect happens at all. Unlike Werkzeug, Django leaves ///evil.example/ as it is.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Unvalidated Redirects and Forwards Cheat Sheet
- Django 6.1: LoginView and success_url_allowed_hosts
- Django 3.0 release notes: is_safe_url() renamed to url_has_allowed_host_and_scheme()
- Django 4.0 release notes: is_safe_url() removed
- Django 6.1: HttpResponseRedirect
- WHATWG URL Standard: basic URL parser
- CodeQL query help: py/url-redirection
- CWE-601: URL Redirection to Untrusted Site ('Open Redirect')