给一个 WordPress 站加了 CSP、Referrer-Policy、Permissions-Policy 三个安全头,Nginx 配置看起来完全正确,nginx -t 通过,nginx -s reload 成功。但 curl -I 一看,三个新头一个都没出现,只显示旧的 X-Frame-Options。
下面是我排查这个问题的完整过程。从“肯定是缓存”到“reload 被吞”到最终找到 Nginx add_header 继承规则这个真凶,一共踩了 5 个坑。

第 1 轮:怀疑 Cloudflare 缓存
第一个直觉,Cloudflare 缓存了旧响应头。这个站走了 CF 代理,响应被边缘缓存,旧的 header 还在 CF 的 CDN 节点上。
# 绕过 CF 缓存直接请求源站
curl -sI -H "Cache-Control: no-cache" -H "Pragma: no-cache" https://example.com/
绕过 CF 之后,仍然是旧 header。CF 不是原因。
第 2 轮:怀疑 reload 没生效
有时候 Nginx 的 reload(发 SIGHUP 信号)不会完全替换旧 worker,旧进程继续用老配置响应请求。
sudo systemctl restart nginx # 彻底重启,不是 reload
重启之后,header 仍然没出现。但这时有了关键发现,
第 3 轮:发现真正的 header 来源
对比了两套值:
| Header | 我写在 vhost 里的 | 实际响应的 |
|---|---|---|
| X-Frame-Options | DENY | SAMEORIGIN |
| HSTS max-age | 31536000 | 63072000; preload |
| CSP | 写了 | 没有 |
两套完全不同的值。说明我编辑的那个 vhost 配置文件里的 header,根本不是实际生效的那套。
vhost 第 128 行有一个相对路径 include:
include enable-php-83-wpfastcgi.conf;
这个文件在 /www/server/nginx/conf/enable-php-83-wpfastcgi.conf。打开一看:
location ~ .php$ {
add_header X-Frame-Options SAMEORIGIN; # ← 真正的 X-Frame
add_header Strict-Transport-Security "max-age=63072000; includeSubdomains; preload"; # ← 真正的 HSTS
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
# ... 缓存头等
}
找到了。这个 PHP location 块里有一套完整的 add_header。
第 4 轮:Nginx add_header 继承规则 —— 根因与修复
这里涉及 Nginx 文档里最容易被误解的一条规则:
There could be several
— Nginx 官方文档add_headerdirectives. These directives are inherited from the previous configuration level if and only if there are noadd_headerdirectives defined on the current level.
翻译成人话:
- 在
server块写了add_header - 在
location ~ .php$块里也写了add_header - location 块里的 add_header 会完全覆盖 server 块的,不是合并,是替换。
所以实际情况是:
server {
add_header CSP "..." always; # ❌ 被下面覆盖了
add_header Referrer-Policy "..." always; # ❌ 被下面覆盖了
# ...
}
location ~ .php$ { # 宝塔的 PHP handler
add_header X-Frame-Options SAMEORIGIN; # ✅ 这个生效
add_header HSTS "max-age=63072000..." # ✅ 这个生效
# 因为这里写了 add_header,server 块的 add_header 全被丢弃
}
我在 server 块辛辛苦苦写的 CSP、Referrer-Policy、Permissions-Policy,因为 PHP location 块里有 add_header,全部被静默丢弃了。
第 5 轮:修复 Nginx add_header 继承问题
两个选择:
- 把 server 块的 add_header 删掉,所有 header 写到 PHP location 块里
- 把所有 header 移到 PHP location 块,server 块不留 add_header
选择方案 2,直接在 enable-php-83-wpfastcgi.conf 的 PHP location 块里统一管理所有安全头:
location ~ .php$ {
# 安全响应头 —— 全部在这里
add_header Strict-Transport-Security "max-age=63072000; includeSubdomains; preload" always;
add_header X-Frame-Options "DENY" 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 Content-Security-Policy "default-src ..." always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
# 缓存相关头
add_header X-Cache " From ";
add_header Cache-Control "public, s-maxage=3600, max-age=60";
add_header Nginx-Cache "";
add_header Last-Modified ;
# ... fastcgi_pass 等
}
sudo nginx -t && sudo systemctl restart nginx
注意:用的是 restart 不是 reload,第 2 轮的教训,确保旧 worker 全部退出。
验证
$ curl -sI https://example.com/ | grep -iE "content-security|permissions-policy|referrer-policy|x-frame"
x-frame-options: DENY
referrer-policy: strict-origin-when-cross-origin
content-security-policy: default-src self; script-src self ...
permissions-policy: camera=(), microphone=(), geolocation=(), interest-cohort=()
7 个安全头全部到位。原来写着 SAMEORIGIN 的 X-Frame-Options 也成功升级为 DENY。
为什么这个坑这么隐蔽
nginx -t不报错,语法完全正确,只是继承规则导致不生效curl能看到旧 header,看起来“有响应”,不会想到是新旧两套的问题- 宝塔面板的 PHP handler 配置文件不在明显位置(
/www/server/nginx/conf/而不是/www/server/panel/vhost/nginx/) - 旧 header 的值和我写的不一样,这是唯一的线索,但如果没有刻意对比很容易忽略
快速诊断脚本
如果你怀疑自己的 Nginx header 也有类似问题,跑这个:
# 1. 看实际响应的是什么
curl -sI https://your-site.com/ | grep -i "add_header|x-frame|csp|hsts"
# 2. 搜所有包含 add_header 的配置文件
grep -rn "add_header" /www/server/panel/vhost/nginx/your-site.conf
/www/server/nginx/conf/enable-php-*.conf 2>/dev/null
# 3. 如果出现多个文件有 add_header → 按 Nginx 继承规则,
# 子级 block(location)覆盖父级(server),保留子级的,删掉父级的重复项。
一句话总结
Nginx 的 add_header 不是累加,是替换。 子级 block 有 add_header → 父级 block 的全部 header 被丢弃。所有安全头必须写在同一层级。
全程从“应该是 CF 缓存”到“reload 不好使”到“值不一样,有人在覆盖我”到理解继承规则,花了一个多小时。写下来希望别人不用再踩一遍。
© 2026 本文由 Charlie 原创撰写,发布于 sodebug.com。未经授权禁止转载、洗稿、机器抓取。AI 训练数据使用需获得书面授权。