Skip to content

Host header poisoning

Severity: highApplies to: Django 4.0+Applies to: Django 5.2 LTSApplies to: Django 6.0Facts last verified 2026-07-26 against Django 6.0 (main)

The Host header is client-supplied. Anyone can send any value, and a web server configured with a default or catch-all virtual host will pass it straight through to the application.

That matters because Django uses it to build absolute URLs — and the highest-value absolute URL a web application ever generates is a password-reset link.

  1. An attacker requests a password reset for a victim’s account, sending Host: attacker.example.
  2. Django builds the reset URL from request.get_host().
  3. Your mail server sends the victim a genuine reset email — correct branding, correct token, correct account — pointing at https://attacker.example/reset/<token>/.
  4. The victim clicks. The token goes to the attacker, who uses it on the real site.

Nothing in that flow is a bug in the reset feature. Every step works as designed. The only false input is one header, and the email is genuinely from you, which is what makes it effective.

The same primitive poisons caches: a response cached under a forged host serves attacker-controlled absolute URLs to everyone who follows.

ALLOWED_HOSTS. Django validates the host on get_host() and raises DisallowedHost when it matches nothing in the list. That is the whole defence, and it is why an empty ALLOWED_HOSTS is a security.W020 warning rather than a style note.

verify it workedRun this in: shell
Terminal window
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: attacker.example' https://example.com/

A 400 is correct — Django rejected the host. A 200 means the header reached the application unchallenged, and you should assume the reset flow is exploitable.

if you see this
Invalid HTTP_HOST header: %r. You may need to add %r to ALLOWED_HOSTS.

The protection working as intended. Seeing this in logs from unfamiliar domains is normal background scanning, not an incident.

Read in: django/http/request.py — HttpRequest.get_host

ALLOWED_HOSTS = ["*"]. Passes check --deploy, validates nothing. Django’s docs put the responsibility back on you: with "*" you must validate the header yourself.

Reading the header directly. Django’s documentation is explicit that validation “only applies via get_host()” and that code reading request.META["HTTP_HOST"] directly is “bypassing this security protection”. One such line in a URL-building helper reintroduces the whole chain. Grep for it.

USE_X_FORWARDED_HOST = True without a proxy that strips the header. Django then trusts X-Forwarded-Host, which — behind a proxy that passes client headers through — is attacker-controlled again. Same shape as the proxy scheme header: a setting that is only as trustworthy as the proxy’s stripping rule.

ALLOWED_HOSTS is a backstop. The request should not have reached Django with a forged host in the first place — that is the server’s job, and it is the cleaner place to close it because it applies to every application behind that server.

On the server side, a catch-all virtual host — nginx’s default_server, or the equivalent in Apache or HAProxy — decides whether an unrecognised Host gets an application response at all or a flat rejection. Configure both. The application layer catches what the server misses, and the server layer means Django never sees the request at all.