Invalid HTTP_HOST header errors in Django and Nginx

Do you have a Django application running behind Nginx and getting lots of Invalid HTTP_HOST header errors, even though you already configured ALLOWED_HOSTS correctly?

It’s a fairly common problem but it’s also simple to fix.

TL;DR: remove the default_server option from your server configuration block so that Nginx doesn’t reply to all HTTP requests for any Host header, and place a new server block with default_server returning a <code>444</code> status (or, if you’re not using Nginx, a <code>422</code> status).

Why does it happen?

Note: my explanation assumes you already configured ALLOWED_HOSTS correctly. If you haven’t, do it and then check back here to make sure you’re doing the Nginx part correctly.

To put it simply, Nginx is proxying to your Django application requests that should be either denied (or maybe proxied elsewhere, if, for example, you have multiple backends).

Many people tend to think this is a problem in their Django application, maybe because Django has an ALLOWED_HOSTS setting that determines for which domain names it should accept requests, but in reality, it is most likely due to a misconfiguration of the Nginx server block that proxies requests to your Django backend.

Some background

Computers communicate on the internet via IP addresses, not domain names. When a piece of software in a computer tries to communicate with another computer via a domain name, like your browser trying to open a website for you, it first needs to translate the domain name to an IP address. This is what the Domain Name System, or DNS, does. For example, if you point your browser to www.borfast.com, it will first ask the DNS what IP address is serving that domain name, will get back a response like 151.101.1.195, and then it will be able to communicate with that IP address.

Very old HTTP servers could only serve a single domain from each IP address. In other words, there was a one-to-one match between domain names and IP addresses. Obviously, this wouldn’t scale well, as it would require a separate network interface for each domain, drastically increasing the cost of hosting multiple sites, as well as other problems. To overcome this, a way to serve multiple domains from the same IP address was devised: name-based virtual hosts.

Multiple domains could now be served from a single IP address but the server would need to be explicitly told which domain was being requested. This required HTTP clients, like browsers, to specify exactly which domain name they were trying to reach and thus, the Host HTTP header was born.

Using this header, which became required in HTTP/1.1, the HTTP client would still communicate via the IP address but would also send that extra bit of information with the request, so that the server knew which domain to serve.

“What does this have to do with the error I’m seeing?”

Glad you asked!

This is the part that may be confusing for some people: your server may get HTTP requests that are not meant for your domain name because the header is set by the client and can be set to whatever the client wants.

The simplest explanation is that someone is testing your server or application to see if they can gain unauthorized access or cause some other issues, so they send HTTP requests with specially crafted Host headers to see what they can do. This is a very deep topic, so I won’t get much into it but keep in mind it could be a problem.

Another possible explanation is that somehow, someone got a wrong or stale DNS response for a domain and now is trying to reach that domain via your server’s IP address, when that domain is actually hosted elsewhere, on another server, with another IP address.

“OK, but I configured Django’s ALLOWED_HOSTS to only serve my domain; why is it still causing problems?”

Going back to what was said above, this is not a problem with Django but Nginx.

Your Nginx configuration probably looks like this or its equivalent:

upstream django_app {
    server unix:///path/to/yoursite/uwsgi.sock;
}

server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name yourdomain.com;
    ...

    location / {
        uwsgi_pass  django_app;
        include     /path/to/yoursite/uwsgi_params;
    }
}

There are many ways to proxy requests from Nginx to Django and this is just one of them. Yours will probably look different, hopefully with HTTPS thrown into the mix, but the key part is the default_server bit. If you don’t have the default_server option, then the server block is probably the only one (or at least the first one) in your Nginx configuration, and Nginx considers it the default.

When Nginx receives a request, it has to determine which server block to use to process the request. It uses the Host header for this and it tries to match it with the server_name option. If the Host header does not match any server_name, or if the header isn’t present in the request, nginx will use the default server to process the request.

This means that any request without a Host header or with a header that doesn’t match the server_name will be served by this default server block and proxied to the Django backend (in this case, via the uWSGI socket declared in the upstream django_app block).

The header is then passed from Nginx to Django and Django checks it againts the ALLOWED_HOSTS setting. When it’s not there, Django throws the Invalid HTTP_HOST header error.

By the way, I imagine this may also be a problem with name-based virtual hosts in Apache, though I never faced this problem with Apache nor have I ever had to fix it, so I don’t know for sure.

How do you actually fix it?

Since a lot of people tend to look at the problem from a Django-based perspective, they also try to fix the problem there, sometimes doing things that can even be dangerous, like suppressing Django’s security warnings or setting ALLOWED_HOSTS to accept any host or domain name.

Unless you know very well what you are doing, you should absolutely not do either of these things. Warnings in logs are telling you that something is wrong. Suppressing them is rarely a good idea. For example, if someone is trying to hack your server using a host header attack, you may not know about it until it’s too late, and you may not have any information for a forensics investigation. The ALLOWED_HOSTS setting in Django ensures that if the value of the Host header doesn’t match what you’re expecting, Django will not process it, so this should also be set appropriately.

As explained above, your problem is actually the Nginx default server proxying requests to Django that should not be allowed through, so what you need to fix is Nginx’s configuration.

Taking the example configuration from before, fixing it is quite simple and has actually been documented in Django&rsquo;s docs for quite some time:

upstream django_app {
    server unix:///path/to/yoursite/uwsgi.sock;
}

server {
    listen 80; // <--- Remove the default_server
    listen [::]:80; // <--- Remove the default_server
    server_name yourdomain.com;
    ...

    location / {
        uwsgi_pass  django_app;
        include     /path/to/yoursite/uwsgi_params;
    }
}

// Add this server block
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    return 444;
}

You have now declared a default server that does nothing and should catch any requests that are not meant for your domain.

Instead of declaring a “catch all” default server, some people prefer to explicitly match the desired domain inside the server block and only serve the requests meant for that specific domain. Like so:

upstream django_app {
    server unix:///path/to/yoursite/uwsgi.sock;
}

server {
    listen 80;
    listen [::]:80;
    server_name yourdomain.com;
    ...

    if ($host !~* ^(yourdomain.com)$ ) {
        return 444;
    }

    location / {
        uwsgi_pass  django_app;
        include     /path/to/yoursite/uwsgi_params;
    }
}

This approach means more configuration text in each server block but if you don’t want to be dependent on a single default server for some reason, it’s a better alternative.

In either case, you’re all done! You should now stop seeing those pesky errors in your logs and/or emails.