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 noadd_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
Yikes! Now those other useful security headers are gone!
Here are your options:
- Don't add headers like that inside
location
blocks. Yeah, that's not always a choice. - Copy-n-paste all the general security
add_header
blocks into thelocation
blocks where you have to have "custom"add_header
entries. - 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 commentGood catch, thanks for the write-up!
Thanks - I was just about to step into this trap!
Thanks, couldn't figure out why headers weren't added until I found your post. Wish nginx had append_header option.
That would be great! Or, make "append" the default when you use `add_header`.
Thanks, nice tips
Good catch. I believe using include headers is good way in such cases.
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.
Life saver! Spent some time trying to figure out why my global headers were not coming through and then found your article.
Thanks!
Thank you so much friend. I reported a bug thanks to you.
https://wordpress.org/support/topic/bug-diskenhanced/
Thanks !
Saved my day! Thanks Peter!
I faced this problem too in the same way you described. Thanks for sharing this with us!
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?
Thank you so much, Peter!!
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.
Thanks for sharing, nice catch!
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.