djrmun.com

nginx add_header doesn't merge, and it takes your security headers with it

I set six security headers at the server level. They were present on the homepage. They were absent on every stylesheet, script and image the site served, and had been for as long as the config existed.

Nothing failed. No warning, no error log, no failed request. nginx -t was happy. The headers were simply gone on a subset of responses, and the only way to find out was to curl -I an asset URL rather than the page — which I had no reason to do, because why would the headers be different?

The rule

add_header is an array directive. Directives like this one are inherited from an outer block into an inner block only if the inner block sets none of its own. The moment a location block contains a single add_header, the entire inherited set is discarded and replaced by whatever that block declares.

Not merged. Replaced.

This is documented behaviour and it applies to add_header, proxy_set_header, expires and several others. It reads as reasonable in the abstract and is a landmine in practice, because the natural way to write an nginx config is exactly the thing that triggers it.

What I had written

The server block set the headers once, for everything:

server {
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "DENY" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Cross-Origin-Opener-Policy "same-origin" always;
    add_header Cross-Origin-Resource-Policy "same-origin" always;
    add_header Content-Security-Policy "default-src 'none'; ..." always;

    location / {
        try_files $uri $uri/ =404;
    }
}

Then, later and in a completely different frame of mind, I wanted long cache lifetimes on static assets. This is the canonical snippet. Every tutorial has it:

location ~* \.(css|js|svg|png|jpe?g|webp|woff2)$ {
    add_header Cache-Control "public, max-age=604800, immutable";
}

That block adds one header and deletes six. Every CSS file, every image, every font now ships with a Cache-Control and nothing else. No CSP. No X-Content-Type-Options. No framing protection.

The homepage still had all six, because location / has no add_header of its own and therefore still inherits. So every check I would plausibly run came back clean.

Why always doesn’t save you

always controls whether a header is emitted for error responses as well as successful ones. It has nothing to do with inheritance. Adding always to all six — which I had done, and which feels like it should mean always — changes nothing about this.

The fix: move the variance into a map, not a block

The instinct is to re-declare all six headers inside the location block. That works, and it means every future header has to be added in two places forever, and the day you forget is the day you’re back here.

Better: don’t create a second block at all. The only thing that actually varies by file type is the value of one header, so compute that value with a map and keep a single set of add_header lines.

map $uri $edge_cache_control {
    default                                 "no-cache";
    ~*\.(css|js|svg|png|jpe?g|webp|woff2)$  "public, max-age=604800, immutable";
}

server {
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "DENY" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Cross-Origin-Opener-Policy "same-origin" always;
    add_header Cross-Origin-Resource-Policy "same-origin" always;
    add_header Content-Security-Policy "default-src 'none'; ..." always;
    add_header Cache-Control $edge_cache_control always;

    location / {
        try_files $uri $uri/ =404;
    }
}

Now there is exactly one place headers are set, one place to add the next one, and no location block anywhere that can silently drop the lot.

A map goes in the http context. If you’re mounting a config into the stock nginx image at /etc/nginx/conf.d/default.conf, that file is included from inside http {} already, so a top-level map there is legal — which is non-obvious, and worth knowing before you go restructuring the whole file to accommodate it.

How to actually check

The bug is invisible if you only ever test the page. Test an asset:

curl -sI https://example.com/ | grep -ci 'x-content-type-options'
curl -sI https://example.com/styles.css | grep -ci 'x-content-type-options'

Two different numbers is the bug. If you want one command that will tell you about it in future, diff the full header sets:

diff \
  <(curl -sI https://example.com/         | tr -d '\r' | cut -d: -f1 | sort) \
  <(curl -sI https://example.com/app.css  | tr -d '\r' | cut -d: -f1 | sort)

Expect Content-Type, Content-Length and ETag to differ. Anything starting with X-, Cross-Origin- or Content-Security- showing up on one side only is this problem.

The general shape

This is the failure mode I keep running into and the reason I started writing these down: the config was valid, the server was healthy, the status code was 200, and the thing I asked for silently didn’t happen.

There’s no exception to catch and no log line to grep. The only defence is knowing that the primitive behaves this way, and checking the response rather than the config.


← All posts