Nginx add_header 继承陷阱与全站安全响应头实战:CSP、HSTS、X-Frame-Options 一次配齐不失效

为什么"安全响应头"总是配了个寂寞

很多站长都干过这件事:看了某篇安全加固的文章,往 Nginx 里加了一行 add_header X-Frame-Options "SAMEORIGIN",保存、reload,然后打开浏览器开发者工具一看——响应头根本没出现。于是换个位置再写一遍,放进 location / 里,还是没出现。最后干脆放弃了。

问题的根源不在你写错了,而在于 Nginx 的 add_header 有一个非常反直觉的规则:它不会继承,而且会被子级配置整体覆盖。这个规则坑了无数人,也是"安全头明明配了却不生效"的头号原因。这篇文章就把这个陷阱彻底讲清楚,然后给你一份可以直接抄的全站安全响应头配置,最后讲每一个头到底防的是什么、怎么验证。

add_header 的继承规则:不是叠加,是覆盖

先看官方文档里那句容易被忽略的话:These directives are inherited from the previous configuration level if and only if there are no add_header directives defined on the current level.

翻译成人话就是两句话:

  • 如果当前层级一个 add_header 都没写,那就继承上一层的全部。
  • 如果当前层级写了哪怕一个 add_header,那么上一层的全部 add_header 全部作废,只用当前这一层的。

注意,是"全部作废",不是"叠加"。这是最要命的地方。看一个典型翻车现场:

# 在 server 块写了安全头
server {
    listen 443 ssl;
    server_name www.example.com;

    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;

    location ~ \.php$ {
        # 这里想给 PHP 响应加一个自定义头
        add_header X-Backend "php-fpm";
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
    }
}

结果:PHP 页面只会返回 X-Backend,而 X-Frame-Options 和 X-Content-Type-Options 全部消失。因为你在这个 location 里写了 add_header,它就不再继承 server 层的两个头了。静态页面(走别的 location 没写 add_header)反而正常。这种"首页正常、详情页裸奔"的现象,就是覆盖规则在作祟。

很多人第一次遇到时以为是缓存问题,清了 CDN 清了浏览器缓存还是不对,白折腾半天。记住这条规则,能省下你一个下午。

致命细节:always 与状态码

add_header 默认只对 200、201、204、206、301、302、303、304、307、308 这些状态码生效。也就是说,如果你的页面返回 404、403、500,默认情况下安全头是不会加上去的。

这看起来无所谓,其实很危险。攻击者可以用一个 404 页面来绕过你的 X-Frame-Options,把一个返回 404 的地址嵌进 iframe 做点击劫持或信息泄露探测。所以几乎所有的安全头都应该带 always 参数:

add_header X-Frame-Options "SAMEORIGIN" always;

加了 always 之后,无论状态码是多少,响应头都会带上。这是安全头配置的默认姿势,别省。

第二坑:多个 add_header 同名,只有最后一个生效

同一个名字的头,在同一个层级写多次,Nginx 只会发送最后一条。比如有人想同时设两个 CSP 或者多个 Set-Cookie,直接写两行 add_header 是不行的,第二行会把第一行顶掉。需要合并成一行、用逗号分隔,或者用 map 拼装成一条。这一点在配 CSP 时最常踩。

配置放在哪一层最合适

既然 add_header 不继承,就必须保证每一个会被访问到的 location 都带上这些头。最省事也最不容易出错的做法,是在每一个 server 块里重复一遍安全头,然后如果某个 location 里不得不写 add_header,就把它需要的安全头也一并复制过去。

更工程化的做法是用 include 把安全头抽成一个片段文件,每个 server 和需要覆盖的 location 都 include 一次:

# /etc/nginx/snippets/security-headers.conf
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Strict-Transport-Security "max-age=31536000" always;

然后在 server 里:

server {
    listen 443 ssl;
    server_name www.example.com;
    include snippets/security-headers.conf;

    location ~ \.php$ {
        include snippets/security-headers.conf;  # 必须再 include 一次
        add_header X-Backend "php-fpm" always;
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
    }
}

看到没?在 PHP 的 location 里又 include 了一次。这就是 Nginx 的"手动继承"——你必须在每个会发响应头的层级重新声明一遍。写起来啰嗦,但这是唯一可靠的办法。

逐个讲透:每个安全头到底防什么

X-Frame-Options(或 CSP 的 frame-ancestors)

防止你的页面被嵌进别人的 iframe,也就是防点击劫持。攻击者做一个透明 iframe 套在你的登录页上,诱骗用户点击。三个取值:DENY(谁都不能嵌)、SAMEORIGIN(只能同源嵌)、ALLOW-FROM(已废弃,别用)。个人站一般用 SAMEORIGIN,如果你的页面完全不需要被嵌,用 DENY 更彻底。

现在更推荐用 CSP 的 frame-ancestors 指令替代它,两者同时写时浏览器优先看 CSP。为了兼容老浏览器,两个都留着不吃亏。

X-Content-Type-Options: nosniff

禁止浏览器"猜"内容类型。默认情况下,浏览器看到类型不明的文件会嗅探内容去猜,比如一个内容是 HTML 的 .jpg 会被当 HTML 执行,配合上传功能就成了 XSS。加上 nosniff 后,浏览器严格按 Content-Type 处理。这一条几乎没有副作用,所有站都该加。

Strict-Transport-Security(HSTS)

告诉浏览器"以后访问我这个域名只用 HTTPS"。设了之后,就算用户手敲 http:// 或点了一个 http 链接,浏览器会本地直接改成 https,不给中间人降级的机会。max-age 单位是秒,一年是 31536000。

千万注意:HSTS 一旦生效,浏览器在 max-age 期间会强制走 HTTPS,你无法撤回(除非用户清缓存)。所以:

  • 先确认 HTTPS 已经完全正常、证书没过期、所有子域都有证书,再加 includeSubDomains。
  • 不要一上来就 max-age=63072000; includeSubDomains; preload。先用 max-age=300 试水,确认没问题再逐步加长。
  • preload 是提交到浏览器内置列表的,一旦提交,移除非常麻烦,测试阶段绝对不要加。

我见过有人手滑加了 includeSubDomains,结果自己一个只有 HTTP 的图床子域直接全站打不开,最后只能换子域名。血泪教训。

Referrer-Policy

控制跳转时带多少来源信息给对方。strict-origin-when-cross-origin 是当前的推荐默认值:同源带完整路径,跨源只带域名且降级时(https→http)不带。这样既保留了自己站内的分析数据,又不会把内页 URL 泄露给外部站点。比起老式的 no-referrer-when-downgrade 更注重隐私。

Permissions-Policy

以前叫 Feature-Policy,用来关掉你不需要的浏览器能力,比如摄像头、麦克风、地理位置、陀螺仪。关掉之后,就算页面上被注入恶意脚本想调用摄像头,也会被浏览器拒绝。个人博客根本用不到这些硬件能力,全关掉最安全:

add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=()" always;

Content-Security-Policy(CSP)

CSP 是所有安全头里威力最大、也最容易配崩的一个。它的核心作用是白名单化一切外部资源:脚本、样式、图片、字体、连接目标,只有你明确允许的来源才能加载。这样即使你的页面被注入了 <script src="//evil.com/x.js">,浏览器也会因为 evil.com 不在白名单而拒绝执行。

但 CSP 的坑也最多。个人站若引用了大量第三方(统计代码、CDN 字体、广告、评论系统),一次性上严格 CSP 极可能把页面搞乱。正确姿势是先用 Content-Security-Policy-Report-Only 跑一段时间,收集违规报告,确认白名单完整后再切换成正式拦截:

# 第一阶段:只报告不拦截
add_header Content-Security-Policy-Report-Only "default-src 'self'; img-src 'self' data: https:; script-src 'self'; report-uri /csp-report;" always;

你可以写一个简单的 PHP 脚本接收 /csp-report 上报的 JSON,或者直接先看浏览器 Console 里的违规日志——用 Report-Only 时,所有被拦的资源都会在 Console 里打出黄字警告,照着警告逐个把合法来源加进白名单即可。

个人站可以先用一个相对宽松但已经很有价值的 CSP 起步:

add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://static.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;

'unsafe-inline' 是为了兼容大量内联脚本和样式,会让 CSP 的防护打折,但比没有强得多。base-uri 'self' 和 form-action 'self' 能挡住 base 标签劫持和表单劫持,几乎零成本,强烈建议带上。

验证:别信猜,用工具和命令

配完之后一定要真正验证,而不是肉眼看 Nginx 配置文件。三种方式:

第一种,curl 看响应头。注意带上 -I 只看头,加 -L 跟随跳转,并且同时测一个 200 页面和一个 404 页面,确认 always 生效:

# 正常页面
curl -sI https://www.example.com/ | grep -iE 'x-frame|x-content|strict-transport|referrer|permissions|content-security'

# 404 页面,验证 always 是否生效
curl -sI https://www.example.com/this-page-not-exist-12345 | grep -iE 'x-frame|strict-transport'

第二种,看浏览器开发者工具的 Network → 点任意请求 → Response Headers。这是最直观的,能确认浏览器实际收到什么。

第三种,用在线评级服务。Mozilla 的 Observatory(observatory.mozilla.org)和 securityheaders.com 会扫描你的站,给出 A~F 的评级,并列出缺失或配置不当的头。这是查漏补缺最快的办法,照着报告一项项补能很快拿到 A。

五个必踩的坑复盘

  • 子 location 里的 add_header 把父级安全头全顶掉。 解法:每次在子级写 add_header,就把安全头 include 一遍。
  • 忘了 always,404/500 页面没有安全头。 解法:所有安全头都加 always。
  • HSTS 一上来就 includeSubDomains + preload,某个子域没 HTTPS 直接下线。 解法:先用短 max-age 试水,确认所有子域都有证书再加强度。
  • 同名头写多行,只有最后一行生效。 解法:合并成一行用逗号分隔,或用 map 拼装。
  • CSP 一步到位上最严策略,页面白屏、样式全丢。 解法:先 Report-Only 收集,再逐步收紧。

安全响应头是投入产出比极高的一件事:几行配置,挡掉一大批低级攻击。但它不像看上去那么简单,add_header 的覆盖规则会一直悄悄坑人。把这篇文章的规则记住,配完用 curl -I 实测一遍 200 和 404,你就能真正落到实处,而不是配了个寂寞。

Last modification:October 8th, 2026 at 07:24 pm

Leave a Comment