In Django, where exactly is the Host header checked against ALLOWED_HOSTS, and why is reading request.META['HTTP_HOST'] directly a security bug?
answer
- no dedicated host middleware
- validation happens on read
- who calls get_host()
- META is the raw header
basics
~10 sDjango validates the host only inside HttpRequest.get_host(); no middleware checks it up front. Code that reads request.META['HTTP_HOST'] or request.headers['Host'] gets the raw, attacker-chosen value and bypasses ALLOWED_HOSTS entirely.
solid answer
~30 sThere is no host-checking middleware: validation is lazy and lives in `HttpRequest.get_host()`. It runs whenever something calls that method, and Django's own code does so a lot: `CommonMiddleware`, `build_absolute_uri()`, the CSRF origin and referer checks, `get_current_site()`, and the login redirect allow-list. `get_host()` picks the raw host (`X-Forwarded-Host` only if `USE_X_FORWARDED_HOST` is on, else `Host`, else `SERVER_NAME` plus port), validates the domain, and raises `DisallowedHost` (400) on a miss. `request.META['HTTP_HOST']` and `request.headers['Host']` are the untouched header, so code that builds a link or cache key from them has no protection, as the documentation warns. Always go through `get_host()` or `build_absolute_uri()`.
code
python · 10 linesfrom django.http import JsonResponse
def activation_link(request, token):
# Unsafe: raw header, never checked against ALLOWED_HOSTS
# host = request.META["HTTP_HOST"]
# Safe: raises DisallowedHost (400) for a host not in ALLOWED_HOSTS
url = request.build_absolute_uri(f"/activate/{token}/")
return JsonResponse({"link": url})go deeper
Remember to read the host with request.get_host() or build URLs with build_absolute_uri(), never from request.META.
Walk through get_host(): raw host selection, the DEBUG fallback, normalisation, matching, and DisallowedHost as a SuspiciousOperation returning 400.
Show where Django itself calls get_host(), what changes without CommonMiddleware, and how you would validate hosts yourself behind ALLOWED_HOSTS = ['*'].
Treat raw-header access as a code-review and lint rule across teams, and decide where canonical domains live in multi-tenant setups.
## Validation is a method, not a gate A common assumption is that Django rejects bad hosts before the view runs. It does not have a dedicated step for that. The check is **lazy**: it happens inside **`HttpRequest.get_host()`** and nowhere else. The documentation for `ALLOWED_HOSTS` says it plainly: this validation only applies via `get_host()`; code that reads the `Host` header directly from `request.META` bypasses the protection. ## What get_host() does, step by step 1. **Pick the raw host** (`_get_raw_host()`): `X-Forwarded-Host` if `USE_X_FORWARDED_HOST = True` and the header is present; otherwise the `Host` header; otherwise `SERVER_NAME`, with the port appended when it is not the default for the scheme. 2. **Resolve the list**: `ALLOWED_HOSTS`, or `['.localhost', '127.0.0.1', '[::1]']` when `DEBUG = True` and the list is empty. 3. **Split and normalise**: separate domain and port, lowercase, drop a trailing dot. A value that is not a valid host yields an empty domain. 4. **Match**: exact entry, leading-dot subdomain entry, or `'*'`. 5. **Return or raise**: the host *with* its port on success, **`DisallowedHost`** on failure. `DisallowedHost` subclasses `SuspiciousOperation`, so the request handler returns **400** and logs to `django.security.DisallowedHost`. ## Who calls it for you Because so much of Django calls `get_host()`, most projects are protected without writing any code: - **`CommonMiddleware`** calls it in `process_request()` on every request. - **`build_absolute_uri()`** and `request.scheme`-plus-host URLs use it. - **`CsrfViewMiddleware`** calls it (catching `DisallowedHost` itself) to build the expected origin for its `Origin` and HTTPS `Referer` checks. - **`get_current_site()`** falls back to `RequestSite`, whose domain is `get_host()`, when `django.contrib.sites` is not installed. - The auth redirect mixin allows `{request.get_host()}` as a redirect target. Remove `CommonMiddleware` and serve a view that never touches any of these, and a bad host can reach your view without a 400. That is legal, as long as your own code never trusts the raw header. ## The bypass | Access | Validated? | Value | |---|---|---| | `request.get_host()` | yes | host with port, after the allow-list check | | `request.build_absolute_uri()` | yes | full URL built from `get_host()` | | `request.META['HTTP_HOST']` | no | raw `Host` header from the client | | `request.headers['Host']` | no | same raw header, case-insensitive access | | `request.META['HTTP_X_FORWARDED_HOST']` | no | raw proxy header, whatever the setting says | Typical bugs that come from the bottom rows: - an "activate your account" email that formats `f"https://{request.META['HTTP_HOST']}/activate/..."`; - a per-tenant cache key built from the raw header, which lets an attacker fill the cache with pages keyed to a forged host; - a multi-tenant lookup that treats the raw header as trusted before any check ran. ## When ALLOWED_HOSTS = ['*'] With `'*'`, `get_host()` returns whatever it was given. The documentation makes you responsible for validating the host yourself, for example in a middleware that is **listed first in `MIDDLEWARE`** so nothing else reads the host before your check. Multi-tenant sites that add customer domains at runtime sometimes do this, validating against a database table of known domains. ## Logging and alert noise Every rejected host produces an error-level record on the **`django.security.DisallowedHost`** logger, and it is not logged to `django.request`. Security loggers propagate to the `django` logger, which with the default configuration emails `ADMINS` when `DEBUG = False`. Internet scanners send arbitrary `Host` values all day, so a correctly configured site can flood the admin inbox. The documented remedy is to route that one logger to a `logging.NullHandler` with `propagate: False` in `LOGGING`. Silencing it is safe precisely because the request was already refused with a 400; what you must not do is widen `ALLOWED_HOSTS` to make the noise stop. ## Rule of thumb - Build URLs with `build_absolute_uri()` or from a configured canonical domain. - Read the host with `get_host()`, never from `META` or `headers`. - Grep code review diffs for `HTTP_HOST` and `headers['Host']`.
- Does a request with a forged Host always get a 400 in Django?Only if something calls `get_host()`. With the default `MIDDLEWARE`, `CommonMiddleware` does so on every request. Without it, a view that never builds an absolute URL or reads the host through Django's API can run normally with a forged `Host`, which is harmless unless your code trusts the raw header.
- Does get_host() return the port?Yes. It returns the host as received, including a port such as `example.com:8000`. The port is only stripped for the allow-list comparison, so an exact-match comparison against `get_host()` elsewhere must account for it.
saying these in an interview costs you the question
- Django rejects bad Host headers in a middleware before any view runs
- request.headers['Host'] is safe because Django already validated it
- X-Forwarded-Host is ignored everywhere, so reading it from META is harmless
- ALLOWED_HOSTS = ['*'] is fine because get_host() still validates syntax