两台跑着同样业务的服务器,一台被安全扫描器标了 5 个中危项,一台首页比对方快 40%,差距都落在几十行 Nginx 配置上。安全响应头是扫描器看你的第一张脸,压缩比直接决定首屏速度。这两件事都是改一次配置就能长期受益的,本文按 Ubuntu 24.04 的 Nginx 1.24 走一遍:7 处配置,每处都带验证方法,改完 nginx -t 能过、curl 能看到结果才算数。
一条命令看清你现在的响应头
改配置之前先摸底。浏览器开发者工具能看,但 curl 更快,一条命令看清全部响应头:
curl -sI https://你的域名 | head -20
典型的默认配置返回里只有 Server: nginx/1.24.0、日期和 Content-Type。这里藏着两类问题:Server 头把精确版本号告诉了所有扫描器;没有任何安全头,意味着浏览器拿到页面后可以随意被 frame 嵌套、可以走 HTTP 回退、MIME 可以被嗅探改写。
安全头的底线共识是 Mozilla Observatory 的评分体系,下面按它要求的项逐个配。
安全头逐项配置:5 个该开、1 个要慎重
安全响应头由 ngx_http_headers_module 模块的 add_header 指令添加,这个模块所有 Nginx 都内置。先记住一个关键限制:add_header 默认只对 200、301、302 这类成功响应生效,404、500 等错误页不带头。要全覆盖就加 always 参数,1.7.5 之后可用。
在站点的 server block 里加这一段:
server {
# ① 强制 HTTPS:浏览器之后一年内不再尝试 HTTP
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# ② 防 MIME 嗅探
add_header X-Content-Type-Options "nosniff" always;
# ③ 禁止被 iframe 嵌套
add_header X-Frame-Options "SAMEORIGIN" always;
# ④ 限制外部资源来源,none 是最严格档
add_header Content-Security-Policy "default-src 'self'" always;
# ⑤ 控制外部 URL 传递的信息
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
逐项说理由。HSTS 告诉浏览器:接下来一年内访问这个域名直接走 HTTPS,连 301 跳转那一个往返都省掉,顺带防降级攻击。includeSubDomains 会把策略扩散到所有子域名,如果你还有子域没上 HTTPS,先别加这个参数。
CSP 的 default-src 'self' 是最严的一档:页面只允许加载同源资源,外链脚本、内联脚本、第三方统计全被浏览器拦掉。安全收益最大,但业务兼容风险也最大——用了第三方统计、外链图片、内联事件的站点直接上会白屏。稳妥路径是先用只报告不拦截的写法观察日志:
add_header Content-Security-Policy-Report-Only "default-src 'self'" always;
浏览器会把违规项上报到控制台,确认没有误伤后再切成正式的 CSP 头。
隐藏版本号
server_tokens off; 写进 http 块,Server 头从 nginx/1.24.0 变成 nginx。版本号本身不构成漏洞,但它让扫描器的指纹匹配从「试试通用 payload」升级成「直奔该版本已知 CVE」,删掉等于白捡一层模糊。真要较真的人发个畸形请求照样能从错误页猜出版本,这层防御值在成本收益比,别指望它挡定向攻击。
三种写不进去的场景
安全头配了却看不到,一般是三种情况。一是继承陷阱(下一节细说)。二是头里带了 Nginx 解析不了的字符导致 reload 报错,nginx -t 会直接指出行号。三是前面还有一层 CDN 或反代,它在回源响应上又盖了一层自己的头——这种要在最外层那台机器上配,或者两边都配。
add_header 继承陷阱:location 里写一行,server 层全军覆没
这是安全头配置里翻车率最高的一条规则:add_header 的继承是「全有或全无」——当前层级只要写了任何一条 add_header,上层(http、server)的所有头全部失效,只生效当前层级这几条。
实际翻车现场:在 server 块配好了 5 个安全头,后来在某个 location 里加了一行缓存相关的 add_header X-Cache $upstream_cache_status;,结果那个 location 下所有安全头集体消失。curl 一验,只剩 X-Cache,扫描器立刻标红。
两个解法。笨办法是把安全头在每个有 add_header 的 location 里复制一遍,头少的时候能用,头一多就是维护灾难。干净的办法是 Nginx 1.29.3 起提供的 add_header_inherit 指令,设成 merge 后子层级在自有头之外追加继承上层全部头:
add_header_inherit merge;
1.29.3 之前的版本没有这个指令,只能复制或者把头统一收敛到一层。还有一点容易忽略:安全头也一样,location 里如果定义了自己的头,安全头必须跟着搬进去。
配置生效后的验证,还是开头那条 curl:
curl -sI https://你的域名
返回里能看到 5 个安全头就说明这层过了。扫一遍全站的主要路径(首页、文章页、404 页)各 curl 一次,404 页要看到头,得确认每处都带了 always。
gzip:模块内置,改默认配置就能生效
安全头管防御,压缩管速度。gzip 是内置能力,Ubuntu 24.04 的默认配置 /etc/nginx/nginx.conf 里就有现成的 gzip 块,但只开了一行 gzip on;,gzip_types 等关键项全被注释——这正是「配了 gzip 却没压缩效果」的常见原因:gzip 模块默认只压 text/html,你的 CSS、JS、JSON 全部原样传输。
把 http 块里的 gzip 配置改成这样:
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml;
gzip_vary on;
四个指令各有讲究。gzip_comp_level 取 1-9,5 是吞吐和压缩比的平衡点,CPU 密集的 8、9 只能把文本再挤小几个百分点,高并发下纯属烧 CPU。gzip_min_length 设 1024 字节,低于 1KB 的文件压完没省多少,还占用一次压缩开销;它靠 Content-Length 判断,chunked 响应会照压。gzip_types 补上静态资源的 MIME 类型,注意 SVG 是 image/svg+xml,别漏。gzip_vary on 加上 Vary: Accept-Encoding,前面挂着 CDN 或代理缓存时没这个头,缓存的压缩版会发给不支持的客户端。
安全性上有一条官方文档明确的提醒:HTTPS 站点对含敏感数据的动态响应开压缩,存在 BREACH 攻击面(攻击者通过注入内容观测压缩后的长度变化还原密文)。静态资源无此顾虑,登录态下的 API 响应要评估,拿不准就不压。
reload 生效后验证:
curl -sI -H 'Accept-Encoding: gzip' https://你的域名 | grep -i content-encoding
# Content-Encoding: gzip
压缩第二层:brotli 比 gzip 再小 15-20%,模块要另装
Brotli 是比 gzip 更新一代的通用压缩算法,同样的文本通常比 gzip 再小 15-20%,现代浏览器全部支持。它是独立的 ngx_brotli 模块,Nginx 官方源码不带,好在 Ubuntu 24.04 的仓库里已经有了现成的动态模块包:
sudo apt install libnginx-mod-http-brotli-filter libnginx-mod-http-brotli-static
装完模块自动注册,nginx -t 能过就说明加载成功。http 块里接着写:
brotli on;
brotli_comp_level 6;
brotli_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml;
模块自己会做协商:请求头带 Accept-Encoding: br 的现代浏览器拿到 brotli,只认 gzip 的老客户端自动回落到 gzip,两组配置并存不冲突。brotli_comp_level 取 0-11,动态压缩选 6 附近;11 是给静态预压缩准备的档位,压缩一次要花秒级时间,不适合在线压每个请求。
libnginx-mod-http-brotli-static 值得单独一说:它让 Nginx 优先返回预压缩好的 .br 文件而不是在线压。构建流程里对 JS、CSS 提前各生成一份 .br 和 .gz,静态站点把 brotli_static on; 和 gzip_static on; 打开,运行时 CPU 压缩开销直接归零——对 1 核小鸡来说,这比反复调压缩级别的收益更大。
两个方案怎么选:一张表
| 维度 | gzip | brotli |
|---|---|---|
| 内置程度 | 官方内置模块,开箱即用 | 需另装动态模块包 |
| 压缩比(文本) | 基准 | 通常再小 15-20% |
| 压缩速度 | 快 | 高级别下明显更慢 |
| 浏览器支持 | 全部 | 现代浏览器全支持 |
| 兼容性风险 | 零 | 老客户端需回落 gzip |
| 推荐用法 | 全站兜底 | 现代浏览器优先,静态走预压缩 |
实际部署两组共存,brotli 协商优先。都上 CDN 的话注意另一件事:很多 CDN 对回源响应做二次压缩或透传,源站和 CDN 各压一层没有意义,压缩策略要和 CDN 的缓存规则配合着调,不然 gzip_vary 缺失导致的缓存错配很难从页面上看出来。
常见问题
Q:配置改完网站没变化?
A:先 sudo nginx -t 确认语法,再 sudo systemctl reload nginx。改的是 .conf 但没 reload 是最常见的原因。
Q:nginx -t 报 add_header 指令不认识?
A:指令写错了位置。add_header 只能出现在 http、server、location(或 if in location)上下文,写在别处会报 is duplicate 或 unknown directive 类错误。
Q:怎么验证压缩真的生效了?
A:curl -sI -H 'Accept-Encoding: gzip, br' https://你的域名 | grep -i content-encoding,返回 br 或 gzip 即生效。想看具体省了多少,对比压缩前后的传输字节数即可。
Q:安全头在首页有、某个路径下没有?
A:就是 add_header 继承陷阱,那个 location 里有自己的 add_header 把上层头顶掉了。按本文「继承陷阱」一节处理。
Q:server_tokens off 能防扫描吗?
A:能抬高中低档扫描器的成本,防不住定向探测。它是卫生习惯,当不起安全边界。
只留三件事:最小改动清单
时间有限就按这个顺序来:第一,server_tokens off 加上 5 个安全头,带 always,改完全站路径 curl 一遍;第二,补全 gzip_types 和 gzip_vary,默认配置里那几行注释展开就能用;第三,装 brotli 模块包,静态资源走预压缩。前两件十分钟,第三件半小时,换来的是扫描器不报警和实打实的首屏提速。