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.

Understanding Django's Host Header Error Emails
Pawel Czerwinski

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:

  1. Spambots and crawlers send HTTP requests to your server. These HTTP requests include an incorrect Host header (more on this in a minute)
  2. NGINX is misconfigured, so passes these requests onto the Django app server
  3. Django is also misconfigured, causing an error.
  4. 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:

Example HTTP request in the browser console

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:

  1. Your NGINX config should be configured for a single host name only.
  2. Your NGINX config should redirect www to non-www.
  3. Your NGINX config should redirect non-SSL to SSL.
  4. 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.