tl;dr; When you use add_header in a location block in Nginx, it undoes all "parent" add_header directives. Dangerous!

Gist of the problem is this:

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.

From the documentation on add_header

The grand but subtle mistake

Basically, I had this:

server {
    server_name example.com;

    ...gzip...
    ...ssl...
    ...root...

    # Great security headers...
    add_header X-Frame-Options SAMEORIGIN;
    add_header X-XSS-Protection "1; mode=block";
    ...more security headers...

    location / {
        try_files    $uri /index.html;
    }
}

And when you curl it, you can see that it works:

$ curl -I https://example.com
[snip]
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Strict-Transport-Security: max-age=63072000; includeSubdomains; preload

The mistake I had, was that I added a new add_header inside a relevant location block. If you do that, all the other "global" add_headers are dropped.
E.g.

server {
    server_name example.com;

    ...gzip...
    ...ssl...
    ...root...

    # Great security headers...
    add_header X-Frame-Options SAMEORIGIN;
    add_header X-XSS-Protection "1; mode=block";
    ...more security headers...

    location / {
        try_files    $uri /index.html;
        # NOTE! Adding some more headers here
+       add_header X-debug-whats-going-on on; 
    }
}

Now, same curl command:

$ curl -I https://example.com
[snip]
X-debug-whats-going-on: on

Bad score on Observatory for www.peterbe.com
Yikes! Now those other useful security headers are gone!

Here are your options:

  1. Don't add headers like that inside location blocks. Yeah, that's not always a choice.
  2. Copy-n-paste all the general security add_header blocks into the location blocks where you have to have "custom" add_header entries.
  3. Use an include file, see below.

How to include files

First create a new file, like /etc/nginx/snippets/general-security-headers.conf then put this into it:

# Great security headers...
add_header X-Frame-Options SAMEORIGIN;
add_header X-XSS-Protection "1; mode=block";
...more security headers...
# More realistically, see https://gist.github.com/plentz/6737338

Now, instead of saying these add_header lines in your /etc/nginx/sites-enabled/example.conf change that to:

server {
    server_name example.com;

    ...gzip...
    ...ssl...
    ...root...

    include /etc/nginx/snippets/general-security-headers.conf;

    location / {
        try_files    $uri /index.html;
        # Note! This gets included *again* because
        # this location block needs its own custom add_header
        # directives.
        include /etc/nginx/snippets/general-security-headers.conf;
        # NOTE! Adding some more headers here
        add_header X-debug-whats-going-on on; 
    }
}

(You need to use your imagination that a real Nginx config site probably has many different more complex location directives)

It's arguably a bit clunky but it works and it's the best of both worlds. The right security headers for all locations and ability to set custom add_header directives for specific locations.

Discussion

I'm most disappointed in myself for not noticing. Not for not noticing this in the Nginx documentation, but that I didn't check my security headers on more than one path. But I'm also quite disappointed in Nginx for this rather odd behaviour. To quote my security engineer at Mozilla, April King:

"add" doesn't usually mean "subtract everything else"

She agreed with me that the way it works is counter-intuitive and showed me this snippet which uses include files the same way.

Comments

Post your own comment
Kevin

Good catch, thanks for the write-up!

Brian

Thanks - I was just about to step into this trap!

Viktor

Thanks, couldn't figure out why headers weren't added until I found your post. Wish nginx had append_header option.

Peter Bengtsson

That would be great! Or, make "append" the default when you use `add_header`.

Binh Thanh Nguyen

Thanks, nice tips

Harry

Good catch. I believe using include headers is good way in such cases.

Dario Zadro

Your write up doesn't seem to be accurate with the latest version of nginx (1.15.8) in my case. The location block is still over-riding the parent include directive.

Bob

Life saver! Spent some time trying to figure out why my global headers were not coming through and then found your article.
Thanks!

Serdar

Thank you so much friend. I reported a bug thanks to you.
https://wordpress.org/support/topic/bug-diskenhanced/

Andrej Szabo

Thanks !

Anonymous

Saved my day! Thanks Peter!

Mouneer

I faced this problem too in the same way you described. Thanks for sharing this with us!

Arijit Biswas

location /blog {
        try_files $uri $uri/ /blog/index.php$is_args$args;
        add_header X-Status "Works" always;
    }

It does not work. The header does not show up. Help?

Pridhvi

Thank you so much, Peter!!

Anonymous

Actually, this is not what I observe (with nginx 1.16.1). If I put a restrictive header like "default-src 'none';" in the top-level nginx.conf, and something like "default-src 'self' 'unsafe-inline';" inside a location block of a site requiring 'unsafe-inline', the site breaks. This behavior is the correct one for two header lines one after the other, because the browser only allows later headers to restrict the CSP, not to relax it.

The behavior I observe is consistent with curl only showing the last header, with the browser seeing them all, because nginx is in fact sending them in order.

Claudio Kuenzler

Thanks for sharing, nice catch!

Olesia

Just smiling since today I went through all the steps of this success story. Moreover, I've started exactly with the same set of the security headers, added the same add_header X-debug-whats-going-on header to test and finished with the same feelings about Nginx and myself))
I've resolved the issue by myself, but anyway, thanks a lot for the idea with config include.

Your email will never ever be published.

Related posts

Go to top of the page