skip to content

In a Django project, how can a forged Host header make password-reset emails link to an attacker's domain, and which settings close that hole?

level: seniorimportance: should knowfreq 42%

answer

  1. where the email link's domain comes from
  2. RequestSite versus a fixed Site
  3. wildcard hosts, trusted forwarded host
  4. pin the domain

basics

~20 s

Without a fixed site, Django's reset email builds its link from request.get_host(). If ALLOWED_HOSTS accepts a forged host, classically '*' plus a relayed X-Forwarded-Host, the attacker's domain lands in the victim's link and the token leaks.

solid answer

~40 s

`PasswordResetView` calls `PasswordResetForm.save()`, which takes the email's `domain` from `get_current_site(request)` unless `domain_override` is passed. With `django.contrib.sites` absent that is a `RequestSite`, whose domain is `request.get_host()`. So the attacker submits the victim's email with `Host: evil.example`; if the host passes validation, the victim receives a genuine email whose link carries a valid token to `evil.example`, and one click hands it over. Validation lets it through when `ALLOWED_HOSTS` contains `'*'` (or a leading-dot entry covering a host the attacker controls); `USE_X_FORWARDED_HOST = True` behind a proxy that relays the client's `X-Forwarded-Host` makes the header attacker-chosen, and with a wildcard that is the classic hole. Fixes: an explicit `ALLOWED_HOSTS`, `USE_X_FORWARDED_HOST` only when the proxy overwrites the header, and pinning the domain via `contrib.sites` with `SITE_ID`, or `domain_override`.

code

python · 6 lines
python
# settings.py
ALLOWED_HOSTS = ["www.example.com", "example.com"]
USE_X_FORWARDED_HOST = False  # the default; enable only if the proxy overwrites the header

INSTALLED_APPS += ["django.contrib.sites"]
SITE_ID = 1  # Site(pk=1).domain == "www.example.com"

go deeper

for a junior

Know that password-reset links are built from the request's host unless a site domain is pinned, and that ALLOWED_HOSTS is what keeps that host honest.

for a middle

Trace PasswordResetForm.save() to get_current_site() and explain the RequestSite, SITE_ID and lookup-by-host cases.

for a senior

Diagnose the proxy case: USE_X_FORWARDED_HOST with a relayed header plus a wildcard list, and show the regression test and the pinned-domain fix.

for a principal

Set a policy that every outbound link uses a configured canonical domain, and review proxy header handling as part of the platform, not per app.

## The attack in one paragraph Password-reset emails contain an **absolute link** with a one-time token. If an attacker can choose the domain in that link, the victim receives a real email from the real site, with a valid token, pointing at the attacker's server. When the victim clicks, the token arrives in the attacker's access log and the attacker resets the password. This is **password-reset poisoning**, and in Django it runs through the `Host` header. ## Where Django gets the link's domain The built-in flow, as of Django 6.1: 1. `PasswordResetView.form_valid()` calls `form.save(request=self.request, use_https=self.request.is_secure(), ...)`. It passes **no** `domain_override`. 2. `PasswordResetForm.save()` sees no `domain_override` and calls **`get_current_site(request)`**. 3. `get_current_site()` decides the source: - `django.contrib.sites` **not installed** gives a **`RequestSite`**, whose `domain` is `request.get_host()`. - installed with **`SITE_ID`** set gives the `Site` row with that id; its `domain` is fixed in the database. - installed **without** `SITE_ID` looks the `Site` up by `request.get_host()`; an unknown host raises `Site.DoesNotExist` instead of poisoning the link. 4. The email template renders `{{ protocol }}://{{ domain }}` followed by the confirm URL with `uid` and `token`. So in the built-in flow the attacker's host reaches the email only on the `RequestSite` path, and only if `get_host()` returns the attacker's value. ## When get_host() returns the attacker's host `get_host()` validates against `ALLOWED_HOSTS`, so a tight list stops a forged `Host` with a 400. These configurations reopen it: | Configuration | Why it is exploitable | |---|---| | `ALLOWED_HOSTS = ['*']` | every host passes validation, including `evil.example` | | `USE_X_FORWARDED_HOST = True` with a proxy that passes the client's `X-Forwarded-Host` through | `get_host()` prefers that header, and the attacker sets it directly | | `'.example.com'` with an attacker-controllable subdomain | a subdomain the attacker owns (say, a user-content host) passes the check | The second row is the subtle one. `USE_X_FORWARDED_HOST` defaults to `False` and the documentation says to enable it only if a proxy which sets this header is in use. "Sets" is the key word: the proxy must **overwrite** the header, not append to or relay what the client sent. The forwarded value is still checked against `ALLOWED_HOSTS`, so the setting is dangerous mainly in combination with a wildcard list. ## Why signing and HTTPS do not help Candidates often reach for the wrong defences: - **The token is genuine.** Django's token generator signs it correctly; the attack does not forge a token, it steals a real one by choosing where the link points. - **HTTPS protects the channel, not the destination.** `use_https` only decides whether the link says `https://`; the attacker can serve a valid certificate for their own domain. - **Rate limiting the form** slows mass abuse but does not stop one targeted email. - **Short token lifetimes** (`PASSWORD_RESET_TIMEOUT`) shrink the window, but a victim who clicks while the token is valid is still compromised. The only real fix is making sure the domain in the email cannot come from the attacker. ## Closing the hole Defence in depth, in rough order of importance: - **Explicit `ALLOWED_HOSTS`** listing only the public names. This alone turns the forged request into a 400. - **`USE_X_FORWARDED_HOST = False`** unless you need it, and when you do, make sure the proxy overwrites `X-Forwarded-Host`. - **Pin the domain** so the email never depends on the request: install `django.contrib.sites` and set `SITE_ID`, keeping the `Site` row's domain correct per environment, or pass `domain_override` to `PasswordResetForm.save()` from a subclassed view. - **Apply the same rule to every email** that contains a link: build it from a configured canonical domain, not from `request.META['HTTP_HOST']`, which bypasses validation entirely. ## How to test for it A regression test posts the reset form with a foreign host using Django's test client (`self.client.post(url, data, headers={'host': 'evil.example'})`) and asserts a 400, or, if a wildcard is unavoidable, asserts that `mail.outbox[0].body` contains only the canonical domain.

  • If ALLOWED_HOSTS is explicit, is USE_X_FORWARDED_HOST = True still a risk?
    Much less: the forwarded value is still validated by `get_host()`, so a forged `X-Forwarded-Host` outside the list gets a 400. The residual risk is a leading-dot entry covering subdomains an attacker controls, and every other consumer of the header that reads it raw instead of through `get_host()`.
  • Why does contrib.sites without SITE_ID not poison the link?
    `get_current_site()` then looks up a `Site` whose `domain` matches `request.get_host()`. A forged host has no matching row, so the lookup raises `Site.DoesNotExist` and the request errors instead of sending an email with the attacker's domain.
  • Does the victim have to enter anything on the attacker's page for the attack to work?
    No. The reset token is in the link's path, so merely loading the URL delivers it to the attacker's server logs; the attacker then opens the real confirm URL with that token and sets a new password.

saying these in an interview costs you the question

  • The reset email is safe because Django signs the token
  • USE_X_FORWARDED_HOST = True is always required behind a reverse proxy
  • Reading the domain from request.META['HTTP_HOST'] in custom emails is equivalent to get_host()
  • ALLOWED_HOSTS = ['*'] is harmless when HTTPS is enforced
  • Only the attacker's own account can be targeted by a forged Host