nginx ssl and security in 2019

I’ve made a few posts in the past about getting nginx up to scratch with security, specifically SSL’s. These are out of date now and pointless importing.

Pretty sure most admins that run nginx are already using it in stripped down chroot environment when security is a concern. However, SSL configurations are still getting regularly overlooked.

Check out the full configuration at the bottom if you are in a hurry.

This is especially common when using something like LetsEncrypt and certbot to just do the configuration automatically. Don’t get me wrong, it’s a really good utility to sort out an SSL quickly, but it has compatibility in mind and not security.

One of the most important things is which protocol to use. If you’re using nginx 1.13 or newer (mainline is well past this now) then you should be using TLSv1.3, otherwise you should be using at least TLSv1.2.

Note though, that in order to actually use TLSv1.3 (and not just have the option “enabled”), you need to compile nginx against a version of openssl that also supports it. CentOS 7 (and epel) do not currently support newer versions of openssl, and if you add the official nginx repos, you will still not have it (even with nginx-mainline).

Instead, you’ll need to compile it yourself, or use a repo like exove: https://packages.exove.com

Anyway, I’m not going to go in to detail about compiling nginx, or using repos, if you are trying to configure an nginx server, I’m assuming you have some experience with either or both of those processes.

At this point I’d also recommend enabling http2 protocol while we are here.

One thing you will need to do before enabling TLSv1.3 is create a Diffie-Hellman key, as you’ll need it for the ciphers we are going to use. This can be done with the following code:

openssl dhparam -out /etc/ssl/nginx/dh.pem 4096

This will take a bit of time on most systems, and you can adjust your config while you wait. It’s very likely you’ll still need to use TLSv1.2 for the time being, so we’ll leave that enabled for now, but feel free to disable it if you know you aren’t going to need it.

http {
    ....
    server {
        ....
        ssl_certificate /etc/ssl/nginx/fullchain.pem
        ssl_certificate_key /etc/ssl/nginx/private.pem
        ssl_dhparam /etc/ssl/nginx/dh.pem
        listen [::]:443 ssl http2 ipv6only=on;
        listen 443 ssl http2;
        ssl_protocols TLSv1.2 TLSv1.3;
        ....

Okay, great we’ve now enabled all the protocols we need, and we are well on our way to a decent set up.

The next part we’ll need to focus on is the ciphers and the algorithm curve used for the DH enabled ciphers (which is all of them).

http {
    ....
    server {
        ....
        ssl_ciphers ECDHE-RSA-CHACHA20-POLY1305:ECDHE-RSA-AES256-GCM-SHA384;
        ssl_ecdh_curve secp384r1;
        ....

This will enable only 5 ciphers. All of them being strong, and they are pretty well compatible with any newish browser. Android 7+, IE 11 (on Windows 10) and newer, Safari 9 and newer, Firefox 47+ and Chrome version 49+.

The weakest cipher is TLS_AES_128_GCM_SHA256, but even this is very strong, and really not worth worrying about removing (and adds a decent amount of compatibility).

The curve value is really quite complicated to explain, and I’m am definitely not someone who is fully up to speed with the technicalities of it. Plenty of others have done better jobs than I could, such as Andrea Corbellini here: https://andrea.corbellini.name/2015/05/30/elliptic-curve-cryptography-ecdh-and-ecdsa/

Anyway, these are cryptographically strong ciphers, and will likely be for a significant amount of time. So lets move on to the next stage.

Let’s setup the session values. This includes ticketing, timeout and cache timings.

http {
    ....
    server {
        ....
        ssl_session_cache shared:SSL:10m;
        ssl_session_timeout 10m;
        ssl_session_tickets off;
        ....

We want to avoid memory fragmentation, so we don’t use the builtin cache, and we want the cache to be shared across all worker processes for efficiency. the “SSL” portion of the cache line is just the name, and we are defining it as SSL for ease. As far as I can tell, this really doesn’t make any difference in security, but feel free to generate a random string if that’s more your thing.

The timeout simply states if a client can reuse the session, and for how long. 10 minutes is the recommended from every source I can find. Longer is unnecessary, and shorter has the potentially for breaking connections when using very limited bandwidth.

We also want to disable session tickets, which are client side and can cause vulnerabilities (such as ticketbleed). In almost all situations, they are unnecessary. You’ll know if you needed to use them, and you will probably not be reading this.

Okay! We are getting there. Now lets configure stapling, bear in mind you’ll need to make sure the intermediate certificates are included, either as part of your fullchain.pem or the older way of adding the CA bundle via the ssl_trusted_certificate directive.

http {
    ....
    server {
        ....
        ssl_stapling on;
        ssl_stapling_verify on;
        resolver 8.8.8.8 8.8.4.4 valid=300s;
        resolver_timeout 5s;
        ....

So a second part of stapling is verifying DNS via one or more nameservers, I’ve just included the Google nameservers here as they are reliable. I’d definitely suggest configuring the resolvers to be internal nameservers if you are deploying nginx on more than a single web server. We also set up how long verification from the resolvers will last, and the timeout of probing the nameservers.

The last part of the SSL configuration you’ll want to set up is adding the STS header.

http {
    ....
    server {
        ....
        add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload";
        ....

This sets the preload to 2 years. You’ll also need to submit a request to add your domain in to the preload list here: https://hstspreload.org

I’d strongly advise reading through the documentation on that page before you do submit your domain though. As you wont be able to server non-SSL web traffic from the domain or any of it’s subdomains once it’s in the list.

Now, you should be able to restart nginx and test your site with something like SSLLabs or the Mozilla Observatory: https://observatory.mozilla.org and you should see green across the board. Hurray!

So now your configuration file should look like this:

http {
    ....
    server {
        ....
        # Listening protocols
        listen [::]:443 ssl http2 ipv6only=on;
        listen 443 ssl http2;
        ssl_protocols TLSv1.2 TLSv1.3;

        # Certificate files
        ssl_certificate /etc/ssl/nginx/fullchain.pem
        ssl_certificate_key /etc/ssl/nginx/private.pem
        ssl_dhparam /etc/ssl/nginx/dh.pem

        # Ciphers to use
        ssl_ciphers ECDHE-RSA-CHACHA20-POLY1305:ECDHE-RSA-AES256-GCM-SHA384;
        ssl_ecdh_curve secp384r1;

        # Sessions
        ssl_session_cache shared:SSL:10m;
        ssl_session_timeout 10m;
        ssl_session_tickets off;

        # Stapling
        ssl_stapling on;
        ssl_stapling_verify on;
        resolver 8.8.8.8 8.8.4.4 valid=300s;
        resolver_timeout 5s;

        # Headers
        add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload";
        ....

Now, we could be done with this, but nginx is still, by default, vulnerable to other forms of attack. The good news is that the rest of the configuration is just setting more headers (except one). So let’s get started.

First, let’s stop some basic XSS vectors. And disable nginx version tokens from being sent.

http {
    ....
    server {
        ....
        # Disable nginx version tokens
        server_tokens off;
        # Headers
        ....
        add_header X-Frame-Options DENY;
        add_header X-Content-Type-Options nosniff;
        add_header X-XSS-Protection "1; mode=block";

All three of these headers tell the client that fundamentally we should be retrieving or sending data (like POST) between domains. And we will never use frames, of any kind.

There’s some new headers that have been recently introduced (at least since my last update on this topic).

Referrer-Policy, which determines what our website will do with referrer headers, I tend to just disable it entirely for privacy.

Expect-CT is how we tell clients that they shouldn’t even load our site if our certificate isn’t in the CT list. More information about CT can be found here: https://www.certificate-transparency.org

Feature-Policy only really applies to mobiles, but basically says what the client should expect our site to want to try and do. I disable everything, but if for whatever reason your site really wants access to the magnetrometer of the device, you should set this to something other than none.

http {
    ....
    server {
        ....
        # Headers
        ....
        add_header Referrer-Policy "no-referrer";
        add_header Expect-CT "enforce, max-age = 30, report-uri = 'https://report-uri.com/r/d/ct/enforce'";
        add_header Feature-Policy "geolocation 'none'; midi 'none'; notifications 'none'; push 'none'; sync-xhr 'none'; microphone 'none'; camera 'none'; magnetometer 'none'; gyroscope 'none'; speaker self;vibrate 'none'; fullscreen self;payment 'none'; ";

The final header is contentious, mostly with web apps, because coding practices are, frankly, diabolical. However, even if you have to set it to really permissive, you should be sending the Content-Security-Policy header. You might have to tweak this, a lot though.

http {
    ....
    server {
        ....
        # Headers
        ....
        add_header Content-Security-Policy "default-src 'none';"

This header *WILL* break most WordPress installs, so be very mindful of what you are doing with it. I would strongly advise reading Scott Thelme’s article on this: https://scotthelme.co.uk/content-security-policy-an-introduction/

And it will take a lot of trial and error to get it to work as you want it to and you probably wont be able to be as restrictive as you want to be with it (I know I’m not on this site). I’d suggest adding all the different directives and set them all to ‘none’ and work backwards from there. Chromium/Chrome’s debugger is very good with this, just look in the console (Ctrl+Alt+J) and see what is failing, and it will likely give you a solution.

And that’s it! Your nginx setup should be pretty well secured for SSL and XSS attack prevention. The final configuration should look like this:

http {
    ....
    server {
        ....
        # Listening protocols
        listen [::]:443 ssl http2 ipv6only=on;
        listen 443 ssl http2;
        ssl_protocols TLSv1.2 TLSv1.3;

        # Certificate files
        ssl_certificate /etc/ssl/nginx/fullchain.pem
        ssl_certificate_key /etc/ssl/nginx/private.pem
        ssl_dhparam /etc/ssl/nginx/dh.pem

        # Ciphers to use
        ssl_ciphers ECDHE-RSA-CHACHA20-POLY1305:ECDHE-RSA-AES256-GCM-SHA384;
        ssl_ecdh_curve secp384r1;

        # Sessions
        ssl_session_cache shared:SSL:10m;
        ssl_session_timeout 10m;
        ssl_session_tickets off;

        # Stapling
        ssl_stapling on;
        ssl_stapling_verify on;
        resolver 8.8.8.8 8.8.4.4 valid=300s;
        resolver_timeout 5s;

        # Disable nginx version tokens
        server_tokens off;

        # Headers
        add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload";
        add_header X-Frame-Options DENY;
        add_header X-Content-Type-Options nosniff;
        add_header X-XSS-Protection "1; mode=block";
        add_header Referrer-Policy "no-referrer";
        add_header Expect-CT "enforce, max-age = 30, report-uri = 'https://report-uri.com/r/d/ct/enforce'";
        add_header Feature-Policy "geolocation 'none'; midi 'none'; notifications 'none'; push 'none'; sync-xhr 'none'; microphone 'none'; camera 'none'; magnetometer 'none'; gyroscope 'none'; speaker self;vibrate 'none'; fullscreen self;payment 'none'; ";
        add_header Content-Security-Policy "default-src 'none';"
        ....