Understanding Django's Host Header Error Emails
One of the most common errors in Django is the annoying host header mismatch error email. While easy to fix, it's also easy to miss the point.
Are you constantly getting emails from your Django website complaining about an "Invalid HTTP_HOST header"?
Error
[!error] > [example.com] ERROR (EXTERNAL IP): Invalid HTTP_HOST header: 'example.com'. You may need to add u'example.com' to ALLOWED_HOSTS.
Have you fiddled with the NGINX configuration to try get them to stop, only to have them slip through time and time again. Me too!
This is a common error message with a simple fix, but it can actually be a bit tricky to understand fully what's going on, so read on.
What's Causing These Errors?
Essentially, the underlying reason for these emails is that NGINX and Django are incorrectly configured and not properly handling the incoming requests.
Here's what's happening:
- Spambots and crawlers send HTTP requests to your server. These HTTP requests include an incorrect
Host
header (more on this in a minute) - NGINX is misconfigured, so passes these requests onto the Django app server
- Django is also misconfigured, causing an error.
- That error gets emailed to you.
Sounds simple, but there's some nuance to fully understand and avoid these errors.
What's The Commonly Suggested Fix
If you Google for this error message, most people will just tell you to update your ALLOWED_HOSTS
header in Django to include the host that's included in the error message.
ALLOWED_HOSTS = ["example.com", "www.example.com", "spamsite.com", ...]
Bad idea! In fact, you might just make things worse by just doing this. So let's take a step back to understand a bit more about what's going on.
About The Host Header
This issue centers around misconfigured Host
headers, so what is this header?
This is a header included in a client's HTTP request that tells the server what resource they want. Have a look at a request in your browser:
Here, we're requesting the resource /blog/
from the host timmyomahony.com
.
But why do we need to include this Host
header? Remember that a server could be handling multiple different websites. That means that the server needs to be told where to send the request. This is the point of the Host
header:
This header is necessary because it is pretty standard for servers to host websites and applications at the same IP address. However, they don’t automatically know where to direct the request.
When the server receives a request, it checks the host header parameter to determine which domain needs to process the request and then dispatches it. Source.
So it's a mandatory header that all requests must include that helps the server route the request to the right application (in our case a Django app running gunicorn
).
By the way
It's important to remember that the Host
header has nothing to do with
actually routing the request to a server. That's all handled by DNS, TCP
etc. That means that the Host
header only becomes relevant when the request
has already arrived at the server. This has tripped me up before.
Host Header Attacks
The really important thing to realise is that the Host
header can be set to anything by the client. This means that the server cannot trust the Host
header. There are a number of potential host header attacks centered around a malicious client providing an incorrect, or amended host header.
To avoid this, Django introduced the ALLOWED_HOSTS
setting. This means that Django will reject any requests that have a Host
header not included in the ALLOWED_HOSTS
iterable. When it does this it sends the very email that's been annoying you in the first place.
The thing is, Django shouldn't be receiving requests with incorrect Host
headers in the first place. They should be filtered out by NGINX.
So if you're getting these emails, something is wrong in NGINX.
What's the Full Fix
Like the error (and Google) says, Django should be configured correctly to reject any other Host
headers using the ALLOWED_HOSTS
setting:
# settings.py
ALLOWED_HOSTS = ["example.com", ]
But more importantly, you should configure NGINX to ensure that invalid requests with incorrect Host
headers never reach Django.
To do this, you first need to make sure you've covered the basics:
- Your NGINX config should be configured for a single host name only.
- Your NGINX config should redirect www to non-www.
- Your NGINX config should redirect non-SSL to SSL.
- Your NGINX config should make sure to pass the
Host
header to Django
Here's an abbreviated example:
server {
server_name example.com;
listen 443 ssl;
ssl_certificate ...
# ...
location / {
# ...
# Let Django know the connection is secure
proxy_set_header X-Forwarded-Proto $scheme;
# Pass the requested host to Django so that it can validate it using ALLOWED_HOSTS
proxy_set_header Host $http_host;
}
}
# Redirect all http (SSL and non-SSL) to https
server {
server_name example.com www.example.com;
listen 80;
return 301 https://example.com$request_uri;
}
# Redirect SSL www to non-www
server {
server_name www.example.com;
listen 443 ssl;
ssl_certificate ...
return 301 https://example.com$request_uri;
}
But most importantly, NGINX should have a default
server block. that should return 444
.
server {
server_name _;
listen 80 default_server;
listen 443 ssl default_server;
ssl_certificate ...
return 444;
}
With NGINX configured in this way, whenever it encounters a request that doesn't specifically have a Host
header example.com
it will return a 444
instead of passing that request onto Django.
Testing
You can test with curl
to make sure everything is properly configured
First, this should return a redirect 301
/308
to from http
to https
:
curl -s -D - -o /dev/null http://example.com
This should return a 200
and the website contents:
curl -s -D - -o /dev/null https://example.com
Similarly if you include the host header explicitly it should also return 200
:
curl --header "Host: example.com" -s -D - -o /dev/null https://example.com
But an incorrect host header should now return a 444
instead of sending an email:
curl --header "Host: spamsite.com" -s -D - -o /dev/null https://example.com
You can also test what an empty host header returns:
curl -i --header "Host:" https://example.com
This will return a 400
but no email.
Why no email?
When you make a request with an empty host header, you will get a 400
, not a 444
as you might expect. This 400
is from NGINX, not Django!
The reason there is a 400
insetad of a 444
is that all valid HTTP request must include a host header. NGINX will return a 400
before any configurations are considered.
This is obviously potentially confusing.
Summary
To summarise, it's very common to get host header emails from Django and they can be very annoying.
While the solution is to add only your valid hosts to ALLOWED_HOST
, there's more to it. You need to make sure NGINX is configured correctly to never send invalid requests to Django in the first place.