加了四条安全响应头,浏览器一条都没收到

reload 成功,四个头却一个都没下发;另一处反了过来,同一个头出现两份,浏览器只认上游那份旧的。问题分别在 add_header 的继承规则和重复头的顺序上。

给站点补安全响应头,本来以为是最没技术含量的一件事:四个 add_header,nginx -t 通过,reload 也成功了。结果在浏览器开发者工具里看,四个头一个都没有。

我先怀疑是浏览器缓存,换成 curl -I,还是没有。再怀疑是 CDN 或者前置 WAF 把响应头吃掉了,绕过它们直连后端,依然没有。最后才发现,配置确实加载了,只是我加的那四行在匹配到的 location 里被整组丢掉了。

这件事后来在另一个站点上又重演了一次,只不过方向反过来:头加上了,但同一个头出现了两份,浏览器只认第一份,而第一份恰恰是上游应用自己发的旧策略。

一、先确认是"没加"还是"没看到"

排查这类问题第一步不是改配置,而是把请求打到能看见原始响应的位置:

# 逐层打,找到头是在哪一层消失的
curl -sI https://www.example.com/ | grep -iE 'strict-transport|x-frame|x-content-type|referrer'
echo '--- 直连后端,跳过前置代理 ---'
curl -sI -H 'Host: www.example.com' http://10.244.18.31:8080/ | grep -iE 'strict|x-frame|x-content'

两层都没有,说明问题在后端 nginx 本身,不在链路上;只有前置有、后端没有,那就是被代理层加上的,得去代理的配置里找。这一步能省掉大量在 WAF 控制台里翻页面的时间 —— 我们第一次就白翻了半小时。

二、add_header 的继承是"替换",不是"合并"

nginx 的 add_header 有一条很容易被忽略的规则:只要当前层级出现了任何一条 add_header,上一层的所有 add_header 全部失效。不是叠加,是覆盖。

我们的配置长这样:

server {
    listen 443 ssl;
    server_name www.example.com;

    add_header Strict-Transport-Security "max-age=86400" always;
    add_header X-Content-Type-Options "nosniff" always;

    location /api/ {
        add_header Cache-Control "no-store";     # 这一行把上面两条全废了
        proxy_pass http://backend_api;
    }
}

/api/ 下的响应只有 Cache-Control,另外两条一条都没有。而首页没有命中任何带 add_header 的 location,所以首页是正常的 —— 这就是为什么"首页有、接口没有",很容易被误判成"接口走了特殊链路"。

修法有两条,选哪条取决于配置规模:

  • 把 add_header 全部收进一个片段文件,每个需要它的 location 用 include 引一次;
  • 或者干脆只在 server 层加,删掉所有 location 里的 add_header,把 Cache-Control 这类按路径变化的头交给上游应用去发。

我们选前者,因为缓存策略必须按路径区分:

# /etc/nginx/conf.d/snippets/security-headers.conf
add_header Strict-Transport-Security "max-age=86400" always;
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 /api/ {
    include /etc/nginx/conf.d/snippets/security-headers.conf;
    add_header Cache-Control "no-store" always;
    proxy_pass http://backend_api;
}

always 这个参数也要带上。不带它,add_header 只在 2xx/3xx 响应上生效,404、500 和 403 全都没有头 —— 而错误页恰恰是最该带 nosniff 的地方。

三、同一个头出现两份,浏览器只认第一份

第二个站点的问题反过来了。头是有的,但 X-Frame-Options 出现了两行:

x-frame-options: DENY
x-frame-options: SAMEORIGIN

前一行是上游应用(代码托管服务)自己发的,后一行是反代加的。浏览器遇到重复的 X-Frame-Options 时行为不一致,有的取第一个,有的直接忽略整个头当作没设。结果是我们以为改成了 SAMEORIGIN,实际仍然是 DENY,嵌 iframe 的页面继续白屏。

反代这边要先删再加:

location / {
    proxy_pass http://backend_git;
    proxy_hide_header X-Frame-Options;      # 先丢掉上游那份
    include /etc/nginx/conf.d/snippets/security-headers.conf;
}

验证方法不是看有没有,而是数个数:

for p in / /api/health /admin/; do
  n=$(curl -sI "https://www.example.com$p" | grep -ci '^x-frame-options:')
  echo "$p  x-frame-options=$n"
done

每个路径都应该是 1。出现 2 就是还有一处没藏掉,出现 0 就是被某个 location 的 add_header 覆盖掉了。

响应头 期望值 常见错法
Strict-Transport-Security max-age=86400(短期过渡) 只在 80 端口配,或写死一年
X-Content-Type-Options nosniff 没加 always,错误页缺失
X-Frame-Options SAMEORIGIN 上游已发 DENY,没 proxy_hide_header
Referrer-Policy strict-origin-when-cross-origin 写在 location 里,把 server 层的全顶掉

四、定义了 map,却从来没有被引用

排查过程中还翻出一段"看起来已经做了"的配置:

map $scheme $hsts_header {
    https "max-age=31536000; includeSubDomains";
    default "";
}

写得挺像那么回事,注释还写着"HTTPS 才发 HSTS"。但全文搜索 $hsts_header,只有 map 里这一处定义,没有任何 add_header 引用它。也就是说这段配置从上线那天起就没有产生过任何效果。

grep 是唯一可靠的确认方式:

grep -rn 'hsts_header' /etc/nginx/ | grep -v '^\s*#'

如果输出里只有 map 那一行,就等于没配。判断"某个变量有没有被消费"这件事,肉眼扫配置很容易漏,尤其是配置文件被模板生成、分成好几个 include 的时候。

五、HSTS 的时长要跟着证书走

我们最后把 max-age 定在 86400(一天),而不是常见的一年。原因很实际:当时证书只剩不到十天有效期。

HSTS 一旦下发,浏览器在 max-age 内会强制把这个域名升级到 HTTPS,而且不允许用户点"继续访问"绕过证书告警。证书过期那一刻,如果 max-age 是一年,用户连"我知道有风险,继续"这个选项都没有,只能清浏览器状态或者换设备。一天的量级,最坏情况第二天就恢复。

includeSubDomains 也一样,不能顺手加上。主域下面挂着对象存储、代码托管这些独立入口,它们各自有自己的证书和续期节奏,主域的 HSTS 一旦覆盖子域,任何一个子域证书出问题都会连带主域被锁。我们把这条留到证书统一续期、并且确认所有子域都覆盖 HTTPS 之后再打开。

顺带记一个运维细节:这套配置是模板生成的,重新生成会把 custom_params 里的定制整段重写。所以每次生成之后必须跑一遍收尾脚本把四个头加回去,并且由一个五分钟的巡检脚本检查头是否还在。少了这一步,"配置被覆盖"会以"安全头莫名其妙消失"的形式出现,而且往往是在下一次安全扫描时才被发现。

六、按路径验收,别只看首页

改完之后我按路径列了一张验收表,每条都跑一遍:

for p in / /api/health /admin/ /static/app.js; do
  code=$(curl -s -o /dev/null -w '%{http_code}' "https://www.example.com$p")
  hsts=$(curl -sI "https://www.example.com$p" | grep -ci '^strict-transport-security:')
  xcto=$(curl -sI "https://www.example.com$p" | grep -ci '^x-content-type-options:')
  echo "$p code=$code hsts=$hsts xcto=$xcto"
done

期望是每一行的两个计数都是 1,code 是 200。静态资源那行容易出问题:如果给它单独配了长缓存,很可能顺手写了一条 add_header Cache-Control,把安全头又顶掉了 —— 我第二次就是这么翻车的。

小结

  • add_header 的继承是替换:当前层级只要有一条,上层全部失效,这是"首页有、接口没有"的典型原因。
  • 所有安全头都加 always,否则 4xx/5xx 错误页上没有,而错误页最需要它。
  • 上游自己发过的头要先 proxy_hide_header,重复头比缺失头更难查,浏览器行为还不一致。
  • 定义了 map 不等于用了它,grep 一遍确认变量真的被 add_header 引用。
  • HSTS 的 max-age 跟着证书剩余有效期走,includeSubDomains 等所有子域都稳定了再开。
  • 验收按路径逐条数头的个数,别用首页的结果代表整站;模板生成的配置,每次生成后都要复查定制是否还在。
查看「中间件」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。

加了四条安全响应头,浏览器一条都没收到 - 起航社区