> Nginx add_header Does Not Inherit: How /media/ Lost Every Security Header
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.
// TABLE_OF_CONTENTS
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_headerdirectives 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.