Security by HTTP headers

Some HTTP security policies are as simple as adding a header to the response. It's common to just add it to your configuration, but did you actually check if this is working as you expected? For Nginx, a relatively popular and commonly used web server, this might seem surprisingly easy, but there's a huge pitfall.

I'll show you in this blog post it's easy to end up with an insecure configuration which you may look good from looking at the server configuration. It's about the add_header Nginx configuration directive that handles scoping completely different from what you may expect.

While I'm not the only one running into it and there are plenty of troubleshooting topics for this directive indicating this pitfall, I still see bad examples online. Lately when I made the same mistake, a colleague noticed we weren't doing HSTS (HTTP Strict Transport Security) anymore after deploying a change involving caching headers. This made me write this up to raise some attention.

By example; Clickjacking protection and HSTS.

Let's go over this by example. Your site is TLS-enabled (HTTPS), it is clickjacking protected, it is HSTS enabled and you're confident it will pass the security scan. Below is the basics of the Nginx configuration for such, as you may consider sensible.

# IMPORTANT! BELOW IS UNSAFE. DON'T COPY-PASTE ME. READ THE BLOG POST.

http {
    # Clickjacking protection, see:
    # https://developer.mozilla.org/en-US/docs/HTTP/X-Frame-Options
    add_header X-Frame-Options SAMEORIGIN;

    # Serve HTTP non-TLS
    server {
        listen              80;
        server_name         www.example.com;
        ...
    }

    # Serve HTTPS
    server {
        listen              443 ssl;
        server_name         www.example.com;
        ssl_certificate     www.example.com.crt;

        # Enable HSTS, only for HTTPS!
        add_header Strict-Transport-Security "max-age=31536000; includeSubdomains";

        root /srv/web/www.example.com/http_root;
        # To my dynamic web application (e.g. fcgi, uwsgi, ...)
        ...

        location /api/sensitive {
            # Responses contain sensitive data; browsers and proxy servers should
            # not cache any of this.
            add_header Pragma "no-cache";
            add_header Cache-Control "private, max-age=0, no-cache, no-store";
        }
        location /static {
            alias /srv/web/www.example.com/static;
            # This content never changes; aggressive caching enabled.
            add_header Pragma "cache";
            add_header Cache-Control "public";
        }
        ...
    }
}

Clickjacking protection header applied globally in the configuration, check. HSTS header present and only on HTTPS, check. And sensitive data is not cached or stored, check.

Now, you're going to test the output in the browser. Does it actually respond with all the headers you would expect? Let's test this with curl:

# HTTP
$ curl -Is http://www.example.com/ | grep -F X-Frame-Options
X-Frame-Options: SAMEORIGIN

# HTTPS
$ curl -Is https://www.example.com/ | grep -F X-Frame-Options

What? We've defined the X-Frame-Options on the http scope that covers both the HTTP and HTTPS server scopes, right?

The answer is, yes, but the add_header for HSTS in the server scope has cleared the X-Frame-Options header in its parent scope.

But, really? It's a totally unrelated header!

Yep. It's behaviour as documented:

There could be several add_header directives. These directives are inherited from the previous level if and only if there are no add_header directives defined on the current level.

Same goes for the caching headers in the HTTPS server block:

# HSTS works for HTTPS, yey!
$ curl -Is https://www.example.com/ | grep -F Strict-Transport-Security
Strict-Transport-Security: max-age=31536000; includeSubDomains

# Also when accessing any actual content?
$ curl -Is https://www.example.com/static/main.js | grep -F Strict-Transport-Security

This could mean that if the user is not actually accessing any content outside the unprotected URIs, he will effectively not see any HSTS protection. For this example configuration it could be a low impact, but for a scope breaking it that covers most of the requests, it could be very harmful!

Possible solutions

Alternative module for setting headers

The ngx_headers_more plugin will by default preserve headers added in the parent scope.

Procedures to install this unofficial plugin may not be a solution for everyone, although it is available in Debian/Ubuntu via the nginx-extras package. It also requires to change existing configurations.

Define a common config snippet

Create files to include always when fiddling with headers. For example:

In http_headers.conf:

add_header X-Frame-Options SAMEORIGIN;

In https_headers.conf:

include http_headers.conf
add_header Strict-Transport-Security "max-age=31536000; includeSubdomains";

In your site configuration:

http {
    include http_headers.conf;

    # Serve HTTP non-TLS
    server {
        listen              80;
        ...
    }

    # Serve HTTPS
    server {
        listen              443 ssl;
        ...
        include https_headers.conf;
        ...
        location /api/sensitive {
            # Responses contain sensitive data; browsers and proxy servers should
            # not cache any of this.
            add_header Pragma "no-cache";
            add_header Cache-Control "private, max-age=0, no-cache, no-store";
            include https_headers.conf;
        }
        ...
    }
}

It will work, it's "copy-paste safe", I'd say, but it has some drawbacks:

  • It suddenly breaks when someone adds a add_header statement in the first server scope.
  • Quite some extra configuration overhead.

Bad examples in the public

Share your thoughts

Have a better solution? Please share it below in the comments!

Also shocked? Feel free to retweet. ๐Ÿ˜ƒ

Share on: Twitter โ„ Hacker News โ„ Facebook โ„ LinkedIn โ„ Reddit โ„ Email


Related Posts


Published

Category

System Administration

Tags

Connect with me on...