首页 / 博客 / 本文

Nginx add_header 继承陷阱:为什么你的 HSTS 会「静默消失」

🚀 Nginx📅 2026-09-18⏱ 10 分钟

很多人给 Nginx 加安全头的方式是:在 http 或 server 层级写一排 add_header X-Frame-Options "SAMEORIGIN" always;,就觉得「全局生效了」。直到某天想加 HSTS,却发现浏览器面板里 Strict-Transport-Security 根本不存在——不是写错,而是 nginx 的 add_header 有一条反直觉的作用域规则。本文用我真实踩过的一个案例讲透它,并给出「三分钟定位」的排查法。

一句话原理。nginx 的 add_header 不继承:一旦某个 location 自己写了 add_header,它就不会再往上继承 http/server/含父 location 层级已定义的 add_header。想在响应里出现哪些头,就要在产生该响应的那个 location(或 server)里全部重新写一遍。

一、事件现场:HSTS 凭啥「时有时无」

配置大概是这样的:

server {
  listen 443 ssl;
  # ...证书...
  add_header Strict-Transport-Security "max-age=31536000" always;
  add_header X-Frame-Options "SAMEORIGIN" always;
  add_header X-Content-Type-Options "nosniff" always;

  location ^~ /assets/ {
    add_header Cache-Control "public, max-age=604800" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    try_files $uri =404;
  }
  location ~* \.html$ {
    expires -1;
    add_header Cache-Control "no-cache, must-revalidate" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    try_files $uri =404;
  }
}

验证时发现:curl -sI https://example.com/ 返回的响应头里,X-Frame-Options 在、HSTS 不在。第一反应是——HSTS 写错或者 nginx 没重载?都不是。

二、为什么根路径也丢了 HSTS

这里有个连环陷阱:请求 / 并不走 location /。因为配了 index index.html,nginx 对 / 做了一次内部重定向到 /index.html,内部重定向会重新走一遍 location 匹配,于是命中 location ~* \.html$。

而 .html 这个 location 自己写了 add_header,按 nginx 规则,它就不再继承 server 层级的任何 add_header——server 层的 HSTS、X-XSS-Protection、Referrer-Policy、Permissions-Policy 全部被「屏蔽」;只剩这个 location 里明写的 Cache-Control / X-Frame / X-Content 三条。

用下面这条命令可以直观看到「命中了哪个 location / 产生了两条 Cache-Control」的破绽:

curl -sI https://example.com/
# Cache-Control: no-cache            ← 来自 expires -1
# Cache-Control: no-cache, must-revalidate  ← 来自这条 location 手动写的 add_header
# 两条 Cache-Control 同时在 → 强烈暗示命中的是同时含这两处的 location

三、add_header 作用的完整规则

把这条规则讲完整,方便以后遇到不心虚:

写法行为
http → server → location 都不写,只在 http 写默认唯一一层运行时全域可见
server 写、location 没写 add_header该 location 的响应继承 server 层的全部 add_header
location 也写了 add_header(哪怕只一条)该 location 完全不再继承上层,只输出自己写的那些
error_page / proxy 等内部玩意儿内部重定向后的响应重新按所在 location 的头生效,同理

所以结论不难记:「输出头 = 当时处理这个请求的那个 location 所定义的那一整套 add_header」。凡是想保持一致的安全头,就要在每个会返回响应的处理块里都放全,或者在 location 之间复用一份(nginx 早期没有现成 include 变量方案,通常直接复制)。

四、正确的修法与工程习惯

修复很简单——把完整的头集合补到每个实际输出响应的 location:

location ~* \.html$ {
  add_header Cache-Control "no-cache, must-revalidate" always;
  add_header Strict-Transport-Security "max-age=31536000" always;
  add_header X-Frame-Options "SAMEORIGIN" always;
  add_header X-Content-Type-Options "nosniff" always;
  add_header X-XSS-Protection "1; mode=block" always;
  add_header Referrer-Policy "strict-origin-when-cross-origin" always;
  add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;
  try_files $uri =404;
}

三个工程习惯避免再踩:

① 用「响应头实测」当唯一标准。配置漂移靠嘴说没用,直接 curl -sID / url 把响应头拉出来看就是真相。HSTS、CSP 这种一旦「静默消失」几乎无感的头,尤其要以实测为准。

② 加完头就跑通关键路径。至少验证 200 页面、404 页面、静态资源、代理接口各一条,因为它们的 location 不同、继承行为不同。

③ HSTS 上线前想清楚 max-age。一旦浏览器记住该域名的 HSTS,你就「被绑定」在 HTTPS 上了(至少在 max-age+includeSubDomains 范围内不能再切回 HTTP)。新站点先给一个较短的 max-age(如 max-age=86400)灰度,验证稳定后再拉长,是稳妥的发布手法。

总结。add_header 的「就近覆盖、不向上继承」是 nginx 配置里少数几个会导致「安全头静默缺失」的坑。它不报错、不警告,只在浏览器面板里缺席。把规律记成一句:头属于产生它的那个 location,而不是写它的那个层级。遇到「我明明写了却不生效」的响应头问题,先查命中 location,再看它的 add_header 集合,八成就是它。
#Nginx#安全#HSTS#排障#Web运维
广告