Host header poisoning
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.
The chain
Section titled “The chain”- An attacker requests a password reset for a victim’s account, sending
Host: attacker.example. - Django builds the reset URL from
request.get_host(). - Your mail server sends the victim a genuine reset email — correct branding, correct
token, correct account — pointing at
https://attacker.example/reset/<token>/. - 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.
What stops it
Section titled “What stops it”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.
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.
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_hostThree ways the protection is bypassed
Section titled “Three ways the protection is bypassed”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.
Do not rely on the application alone
Section titled “Do not rely on the application alone”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.
Related
Section titled “Related”- ALLOWED_HOSTS — the control that closes this
- Application vs server — why this one spans both