加了四条安全响应头,浏览器一条都没收到
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等所有子域都稳定了再开。 - 验收按路径逐条数头的个数,别用首页的结果代表整站;模板生成的配置,每次生成后都要复查定制是否还在。
正在加载评论…