> Nginx add_header Does Not Inherit: How /media/ Lost Every Security Header

Security Engineering 中级 2026-07-30 04:54 2026-07-30
#nginx #security headers #X-Content-Type-Options #MIME sniffing #stored XSS #configuration

Security headers were configured once in the server block and applied to most of the site. Adding a single Cache-Control line to /media/ silently deleted all of them for uploaded files.

Nginx add_header Does Not Inherit

Uploaded files on this site were served with no X-Content-Type-Options: nosniff. Every other path had it. The headers were configured correctly and had been for as long as the config existed.

The config

server {
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options        "SAMEORIGIN" always;
    add_header Referrer-Policy        "strict-origin-when-cross-origin" always;

    location / {
        proxy_pass http://127.0.0.1:8000;
    }

    location /media/ {
        alias /home/ubuntu/MyCMS/MyCMS/media/;
        add_header Cache-Control "public, max-age=31536000";
        # ^ this one line removed all three headers above
    }
}

The rule

Directives are inherited from the previous configuration level if and only if no add_header directives are defined on the current level.

add_header is replace, not merge. A location block that declares any add_header discards every inherited one. The same applies to expires, proxy_set_header, and several others — this is a general nginx inheritance behaviour, not a quirk of add_header.

The always flag does not help. It controls which response codes get the header, not inheritance.

Why it matters here

/media/ is where user-uploadable content lives. Without nosniff, a browser is free to ignore the declared Content-Type and sniff the bytes instead. Upload something that looks like HTML, get it served from your origin, and you have stored XSS on your own domain — with full access to same-origin cookies.

This is exactly the path where the header matters most, and exactly the path that lost it.

Fixes, worst to best

1. Repeat the headers in every block. Works, but every future block is a new chance to forget:

location /media/ {
    alias /home/ubuntu/MyCMS/MyCMS/media/;
    add_header Cache-Control "public, max-age=31536000";
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options        "SAMEORIGIN" always;
    add_header Referrer-Policy        "strict-origin-when-cross-origin" always;
}

2. Put them in an include and pull it in everywhere. One source of truth, still one line to forget:

# /etc/nginx/snippets/security-headers.conf
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options        "SAMEORIGIN" always;
add_header Referrer-Policy        "strict-origin-when-cross-origin" always;
location /media/ {
    alias /home/ubuntu/MyCMS/MyCMS/media/;
    add_header Cache-Control "public, max-age=31536000";
    include snippets/security-headers.conf;
}

3. Use headers-more (more_set_headers), which does inherit. Requires the module, so often not an option on a distro package.

Verify from outside, per path

Reading the config is what produced the bug. Ask the server instead, and diff across paths rather than eyeballing one:

for p in / /static/style.css /media/images/example.png /documents/1/paper.pdf; do
  echo "== $p"
  curl -sI "https://example.com$p" | grep -iE 'x-content-type|x-frame|referrer'
done

A path with fewer header lines than its neighbours is the finding. This takes seconds and needs no access to the config at all — which is the point: the config is the hypothesis, the response is the evidence.

Related: the typo that shipped DEBUG=True.