Nginx add_header 加了响应头却让安全头全部消失:继承规则的"全有或全无"与三种收敛写法

加了 add_header,为什么响应头反而少了一个

这是一个真实到不能再真实的场景:你的 Nginx 站点在 http 块里统一加了安全响应头,在 location 块里又想针对静态资源单独加一个 Cache-Control。配置写完之后 nginx -t 通过、reload 成功,但你 curl -I 一看:安全头全没了,只剩 Cache-Control。

很多人第一反应是"配置没生效"或者"被上游覆盖了",于是去查 CDN、查应用层,绕了一大圈。真正的原因藏在 Nginx 的 add_header 指令一条不太直观的规则里:add_header 不继承,只要子层级里出现任何一条 add_header,父层级的所有 add_header 全部失效。

这个问题在个人站长圈里发生的频率极高,尤其在给站点加 HSTS、CSP、X-Frame-Options 这些 SEO/安全相关响应头的时候。本文把这个机制彻底讲清楚,并给出三种可靠的写法。

一、先复现问题,把机制看清楚

写一个最小配置来复现:

server {
    listen 80;
    server_name test.local;

    # 父层级:加了两个全局安全头
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;

    location / {
        root /var/www/html;
        return 200 "hello";
    }

    location /static/ {
        root /var/www/html;
        # 子层级里加了一条缓存头
        add_header Cache-Control "public, max-age=31536000" always;
    }
}

验证结果:

curl -sI http://test.local/ | grep -iE 'x-frame|x-content|cache-control'
# 输出:
# X-Frame-Options: SAMEORIGIN
# X-Content-Type-Options: nosniff
# (没有 Cache-Control,符合预期)

curl -sI http://test.local/static/a.css | grep -iE 'x-frame|x-content|cache-control'
# 输出:
# Cache-Control: public, max-age=31536000
# (两个安全头都不见了!)

这就是标准的"覆盖"行为。Nginx 官方文档里 add_header 的说明中有一句很容易被忽略的话,大意是:这些指令只有在当前层级没有任何 add_header 被定义时才从上一层级继承。一旦当前层级定义了哪怕一条,继承链就断开,父层级的所有 add_header 都不再生效。

这个设计跟 proxy_set_header 的"逐条继承"完全不同,所以从其他指令迁移过来的人特别容易踩。

二、为什么 Nginx 要这么设计

理解设计动机有助于记住这个坑。响应头是有冲突语义的,比如 Cache-Control 你在父层级写了 no-cache,子层级又想要 max-age=31536000,如果两者都发出去,客户端行为就不可预测了(HTTP 规范下多个 Cache-Control 会合并,结果是矛盾的)。

所以 Nginx 选择了最简单的策略:子层级定义了就完全接管,不再合并。这保证了你在某个 location 里写下的响应头就是最终结果,不会有"看不见的父级头"混进来。代价就是你必须重复声明,或者用变量统一管理。

注意区分:proxy_set_header、fastcgi_param 这类是逐条独立继承的,父级设了 A、子级设了 B,最终 A 和 B 都在。而 add_header 是"全有或全无"。同一个配置文件里两种语义并存,这是最容易混淆的地方。

三、方案一:把所有 add_header 用 include 收口到一处

最省事、也最推荐个人站长使用的方案:建一个 snippet 文件,所有需要这些头的层级都 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;

然后每个 location 里需要时都 include:

server {
    listen 80;
    server_name test.local;

    include snippets/security-headers.conf;

    location /static/ {
        root /var/www/html;
        include snippets/security-headers.conf;   # 必须重复这一行
        add_header Cache-Control "public, max-age=31536000" always;
    }
}

这样做的核心好处是单一数据源:以后要加 HSTS,只改 snippet 一个文件,所有 include 它的地方同步生效。缺点是每个 location 都要记得写 include,漏了就静默失效——所以建议配合下面的自动校验。

自动校验脚本(把期望的头和实际返回的头做比对,挂到部署流程里):

#!/bin/bash
# check_headers.sh  部署后执行,确保关键响应头没有静默消失
set -uo pipefail
HOST="$1"
URLS=(
  "http://$HOST/"
  "http://$HOST/static/test.css"
  "http://$HOST/api/health"
)
REQUIRED=("X-Frame-Options" "X-Content-Type-Options" "Referrer-Policy")
FAIL=0

for u in "${URLS[@]}"; do
  H=$(curl -sI --max-time 10 "$u")
  for r in "${REQUIRED[@]}"; do
    if ! echo "$H" | grep -qi "^$r:"; then
      echo "[FAIL] $u 缺少 $r"
      FAIL=1
    fi
  done
done

[ $FAIL -eq 0 ] && echo "[OK] 所有关键响应头齐全"
exit $FAIL

用法:./check_headers.sh yourdomain.com,返回非 0 就说明有 location 漏配。把它放进 CI 或每次补丁后的手工检查清单,这类问题就永远不会再漏到线上。

四、方案二:用变量 + map 集中控制(适合多站点统一管理)

如果你有多个站点、每个站点的响应头要求还不一样,用变量会更灵活。add_header 的值可以是变量,变量可以在更上层通过 map 定义。

# http 块里定义变量
map $host $frame_opt {
    default            "SAMEORIGIN";
    "embed.example.com" "";        # 这个域名允许被 iframe,值给空
}

map $host $hsts_value {
    default            "max-age=31536000; includeSubDomains";
    "test.local"       "";          # 测试域名不发 HSTS
}

map $request_uri $cache_control {
    default                          "no-cache";
    "~*\.(css|js|png|jpg|jpeg|gif|webp|woff2?)$"  "public, max-age=31536000, immutable";
}

server {
    listen 80;
    server_name test.local;

    # 只有非空时才发
    add_header X-Frame-Options $frame_opt always;
    add_header Strict-Transport-Security $hsts_value always;
    add_header Cache-Control $cache_control always;

    location / { root /var/www/html; }
}

重要陷阱:当变量求值为空字符串时,Nginx 会发出一条值为空的头吗?答案是否定的——Nginx 会跳过值为空的 add_header。这正是我们上面用空字符串实现"条件不发"的依据。但这个行为有版本差异,建议实测确认:

# 验证空值头是否被跳过
curl -sI http://test.local/ | grep -i strict-transport
# 若上面 map 把 test.local 映射为空,这里应该没有输出

另外,用变量方案时要注意:如果 $hsts_value 引用的变量未定义,Nginx 会报配置错误,所以 map 里一定要写 default 分支。

这种写法的最大优点:所有 location 里都不用再写 add_header,全部收敛到 server 层级,从根上避免了覆盖问题。缺点是响应头的取值逻辑散落在 map 里,可读性下降,适合对 Nginx 比较熟的站长。

五、方案三:只在 http 层级定义,location 用其他手段

还有一种思路是反过来:把安全头全部放在 http 或最外层 server,locations 里坚决不写 add_header,静态资源的缓存策略改用别的机制实现:

# 静态资源缓存也可以用 map + 变量(上一节方案)解决
# 或者用 expires 指令(它不属于 add_header,不受覆盖影响)

location /static/ {
    root /var/www/html;
    expires 1y;                      # 自动加 Expires 和 Cache-Control
    add_header Cache-Control "public, max-age=31536000, immutable" always;
    # ↑ 这一条仍然会触发覆盖问题,所以此法只适合所有头都在 http 层级的情况
}

说实话,方案三单独用并不彻底,因为 expires 产生的 Cache-Control 是 Nginx 自动加的、不参与 add_header 的覆盖判断,但你自己写的那条 add_header 依然会切断继承。所以实际推荐组合是方案一 + 方案三:安全头统一 include,静态资源用 expires 而不手写 Cache-Control。

location /static/ {
    root /var/www/html;
    expires 1y;
    include snippets/security-headers.conf;   # 关键:显式重复
}

# 结果:Cache-Control 由 expires 生成(含 max-age=31536000)
#       安全头来自 include,一个都不少

六、几个容易忽略的相关坑

1. always 参数的作用

add_header X always; 里的 always 表示无论响应码是什么都发送。不带 always 时,只有 200、201、204、206、301、302、303、304、307、308 这些状态码才会发送头。这解释了另一个常见困惑:为什么 4xx/5xx 错误页上安全头不见了——因为忘了写 always。

# 错误页也要安全头的正确写法
add_header X-Frame-Options "SAMEORIGIN" always;
# 而不是
add_header X-Frame-Options "SAMEORIGIN";

2. if 块里的 add_header

if 块内也有 add_header 时,情况会变得更混乱:if 块算一个独立层级。能不用 if 就不用,这是 Nginx 圈的共识("if is evil")。需要条件逻辑时,用 map 变量代替。

3. 上游应用发的头会不会被覆盖

Nginx 的 add_header 是追加行为,不删除上游已有的头。所以如果你的 PHP 应用自己发了 X-Frame-Options: DENY,Nginx 又 add_header 了 SAMEORIGIN,客户端会收到两条同名头,行为取决于浏览器(多数会取第一个或用最后一个,不一致)。要覆盖上游的头得用 more_set_headers(需要 headers-more-nginx-module):

# 需要 ngx_http_headers_more_filter_module
more_set_headers "X-Frame-Options: SAMEORIGIN";

# 确认模块是否已编译
nginx -V 2>&1 | tr ' ' '\n' | grep -i headers-more

或者更省事:让应用层不要重复发,把响应头的唯一来源放在 Nginx。

4. add_trailer(HTTP/2 trailer)有同样的继承规则

如果你在用 HTTP/2 的 trailer,add_trailer 的继承语义跟 add_header 完全一致,别指望它表现得不同。

七、检查清单:加任何一个响应头前先过一遍

  1. 这个头要加在哪个层级?如果只在 server 层级加一次就够,优先这么做,避免一切继承问题。
  2. 如果必须加到 location,问自己:这个 location 是否存在其他 add_header?如果存在,父级的头会不会被我切断?
  3. 需要重复的时候,用 include snippet,而不是复制粘贴——同一个头写两遍迟早不一致。
  4. 所有安全头都加 always,保证错误响应也带。
  5. 用 curl -sI 分别验证正常页、静态资源、404 页三种情况的响应头,别只看首页。
  6. 把 check_headers.sh 挂到部署流程,让静默失效变成显式失败。

八、一句话总结

add_header 的继承是"全有或全无":子层级一旦出现任何一条 add_header,父层级的所有 add_header 在当前层级失效。避坑的最优解不是记住这条规则然后小心地重复,而是用 include snippet 或 map 变量把响应头收敛到一处,让配置结构本身不可能出错。响应头这东西平时没人看,出问题的时候往往已经影响到 SEO 收录和安全评分了,值得花十分钟把结构理顺。

Last modification:September 27th, 2026 at 07:25 pm

Leave a Comment