Fixing the Host Header vulnerability with Nginx

Some time ago, I was investigating an error report from Sentry on Tentacle. I noticed something baffling: the URL wasn't mine. The error was definitely from my site, but the URL it happened on was not mine. After wondering if someone had managed to deploy my private code elsewhere, I eventually realized I was looking at a case of Host header injection.

Investigation

A HTTP 1.1 request consists of a line containing the requested path and method, followed by zero or more lines of headers, something like this:

GET /path HTTP/1.1
Host: somedomain.com

This request is sent to a server. But one server can host multiple sites, so the Host header is often used to determine which site/app to route the request to. When coding a site, it might feel like the host is something provided by your server, something you can trust. But it isn't! It's user input (like all HTTP headers), so it can be an attack vector.

In a Host header injection attack, the attacker tries to make your site think it's running on a different domain, which opens up several possibilities. For instance, if your site generates password reset links using the current domain, an attacker could trick you into generating links pointing to their site. Then when a user clicks on one of those, they're taken to the attacker's site instead. The attacker can then retrieve the reset token and use it on your site to change the user's password.

To confirm, I tried to replicate the injection request. There are different ways of trying this; the simplest way is to set a different Host header from the URL you're requesting.

curl http://mydomain.com \
  --header 'Host: someotherdomain.com'

It didn't work at first. My site is behind CloudFlare, which adequately protects against this kind of thing, so it returned an error. But then I switched the request URL to my server's public IP, and it worked, unfortunately. My site, mydomain.com, had loaded, but the request logs were showing the Host as someotherdomain.com. Welp.

Resolution

Alright, so I had confirmed that it worked. Now to find a fix. I wanted to cut it off at the source: my site sits behind an Nginx server, so I knew I could configure it to reject requests like this. It was surprisingly difficult to find information on this, but after some tests, I came up with this:

server {
    listen 80 default_server;
    server_name mydomain.com;
    
    if ( $host !~* ^(mydomain.com|www.mydomain.com)$ ) {
        return 444;
    }
    if ( $http_host !~* ^(mydomain.com|www.mydomain.com)$ ) {
        return 444;
    }

    # Other stuff...

    location ~ \.php$ {
        # Other PHP FastCGI configs...
        
        fastcgi_param HTTP_HOST $http_host;
    }
}

There are two main parts to the fix:

  • The two if statements check if the host matches one of my expected domains; if not, they kill the request with a 444. I added two checks because the values of $http_host (Host header) and $host (Nginx server name) may differ.

  • The fastcgi_param directive sets the value of the Host header that is passed to PHP. This was necessary because I noticed that if I made a raw request like this (two Host headers):

    GET / HTTP/1.1
    Host: mydomain.com
    Host: someotherdomain.com
    

    the if-checks would pass (because of the first Host header), but the second Host header would be passed to my app. I'm not sure why that happens, but adding the FastCGI directive ensures that the Host header seen by Nginx is the same one seen by PHP.

There are probably more direct ways to configure Nginx for this, but this was the best I could come up with. 😄

Assessment

Luckily, it was unlikely the attacker had been able to achieve any damage with this, and manual checks confirmed this. My app doesn't use the Host header for anything or output it anywhere. And mature web frameworks like Laravel don't rely on the Host header. Laravel requires you to set an APP_URL environment variable, which is what it uses for URL generation.

I'm not new to bots trying exploits—even before I launched Tentacle, I've been seeing requests to /wp-admin and friends. My strategy for dealing with these is to use tools that are secure by default, backed up with automated security scans, and manual reviews. I didn't know about this vulnerability before I encountered this, but this was a good reminder to never trust user input, and use tools that take security seriously.

Check your framework

Some time after publishing this, I was surprised to find out Rails' url helpers generate urls based on the current host, which means it is vulnerable to this (confirmed). Luckily, Rails 6 introduced Rails.application.config.hosts, which allows you to whitelist the allowed domains for your app. However, it's inactive by default, so you need to explicitly specify your app's domains to get that protection.

In general, if a framework doesn't require you to specify the URL it's running on (like Laravel's APP_URL), it's vulnerable. Note that being vulnerable means that the exploit may succeed, but not necessarily that the attacker might be able to achieve anything with it. Still, you should ensure you always whitelist the expected domains for your app, either via Nginx or an app-level setting like a custom middleware.

Appendix

  1. If you're interested in reading more about this vulnerability, here are a few links:

  2. If you'd like to test this thoroughly, you'll need to send a raw HTTP request, as many request libraries (including curl) limit the weird things you can do (like duplicate headers). A readily available option on Linux is netcat. Create a file containing the request:

    GET / HTTP/1.1
    Connection: close
    Host: yourdomain.com
    Host: someotherdomain.com
    

    then pipe that to a netcat session to your server's IP:

    cat raw.http | nc 144.45.71.8 80
    


I write about my software engineering learnings and experiments. Stay updated with Tentacle: tntcl.app/blog.shalvah.me.

Powered By Swish