1. 安全头到底解决了什么问题
做 Web 服务的人应该都有过这种经历:应用明明写得挺稳,代码也没有明显漏洞,结果一上扫描器就是一片红。点开报告一看,大部分问题根本不是应用逻辑出错,而是响应头缺失、Cookie 属性不对、MIME 类型猜测这些“边缘地带”的毛病。说白了,这是 HTTP 协议层面留给攻击者的空间,而 Nginx 作为最外层网关,恰好是把这些空间堵上的最佳位置。
我最早接触安全头是在一次内部安全审计之后。当时负责的一个业务系统被第三方扫描出十几项中高危问题,开发团队很委屈,说代码里没有 SQL 注入也没有文件上传漏洞。后来逐条看,十有八九是 X-Content-Type-Options 缺失、Clickjacking 防护没有、HSTS 没开这类问题。修复方式也特别简单——不需要改业务代码,只需要在 Nginx 配置里加上几行响应头即可。那一次之后我养成一个习惯:不管项目大小,Nginx 配置里必带一套完整的安全头模板。
安全头并不是什么高深技术,它就是一组 HTTP 响应头字段,告诉浏览器“这个响应应该怎么处理、哪些能力可以禁用、哪些行为需要限制”。浏览器是执行方,服务器是策略方,策略方把话说清楚,执行方就能帮用户挡住一大批常见攻击。
适用对象很广:只要你的服务是通过 Nginx 对外提供的,无论是静态站点、反向代理、负载均衡,还是作为 API 网关,都可以在 Nginx 这一层统一加上安全头。这样做的好处是一处配置、全局生效,不用跑到每个应用里去改代码。
2. 每个安全头的含义和配置方法
2.1 X-Content-Type-Options: nosniff
这个头最简单,但作用不能小看。它告诉浏览器:不要对响应内容做 MIME 类型猜测,服务器声明是什么类型,就按什么类型处理。
举个例子,你上传了一个文件,服务端返回的 Content-Type 是 text/plain,但如果内容里嵌了 HTML 标签,有些浏览器会自作主张把它当 text/html 渲染。攻击者可以利用这个特性,把恶意脚本伪装成普通文件传上去,用户一点开就中招。加了 nosniff 之后,浏览器老实了,声明 text/plain 就按纯文本显示,不再瞎猜。
add_header X-Content-Type-Options "nosniff" always;注意后面的 always 参数,它表示不管响应状态码是 200 还是 404、500,都会带上这个头。如果不加 always,Nginx 默认只对 200、201、204、206、301、302、303、304、307、308 这些“标准响应”生效,错误页就漏掉了。加了这个参数,保证所有响应都覆盖。
2.2 X-Frame-Options: SAMEORIGIN
这个头管的是“能不能被别家页面套进 iframe”。攻击者经常构造一个恶意页面,用 iframe 把你网站的登录页嵌进去,用户在不知不觉中把账号密码输入到看似正常的框里,实际信息却被截获,这就是经典的点击劫持(Clickjacking)。
SAMEORIGIN 表示只有同源页面才能用 iframe 嵌入本站内容。如果你确定自己的页面绝对不需要被第三方站点嵌入,可以直接用 DENY,完全禁止。
add_header X-Frame-Options "SAMEORIGIN" always;顺带一提,这个头虽然老,但兼容性极好,是所有浏览器都会认的。后来出现的 Content-Security-Policy 的 frame-ancestors 指令,功能上部分重叠,但 X-Frame-Options 依然是保底方案。
2.3 X-XSS-Protection: 1; mode=block
当年 IE 和早期 Chrome 内置了一个 XSS 过滤器,这个头用来控制它的行为。1; mode=block 表示开启过滤器,如果检测到反射型 XSS 攻击,浏览器直接阻断页面渲染。
这里要诚实地说明,现代浏览器已经逐渐移除了这个内置过滤器,因为过滤器本身也有被绕过的风险,反而可能引入问题。但考虑到一些老版本的浏览器仍然存在,加上它成本极低、副作用极小,还是可以保留一行。
add_header X-XSS-Protection "1; mode=block" always;如果你用的是 Chrome 或 Firefox 最新版本,这个头基本是心理安慰,但心理安慰在某些行业合规扫描里能帮你少几个告警。
2.4 Referrer-Policy: strict-origin-when-cross-origin
这个头控制的是浏览器在跳转、加载子资源时,要不要在 Referer 字段里带上来源地址。
默认情况下,浏览器会在跨域请求时带上完整的来源 URL,包括路径和查询参数。这意味着你的 URL 如果带了个 token 或者敏感参数,跳转到第三方站点时,对方可能从 Referer 里看到这些信息。泄露面不小。
strict-origin-when-cross-origin 的含义是:同源请求带完整路径,跨域请求只在 HTTPS 场景下带来源域名,降级到 HTTP 时完全不发送。这个策略既兼顾了统计需求,又最大程度避免了敏感信息泄露。
add_header Referrer-Policy "strict-origin-when-cross-origin" always;这也是目前行业里公认的默认推荐值,没有特殊需求的话不用改。
2.5 Permissions-Policy
这个头用来禁用浏览器的一些敏感功能,比如摄像头、麦克风、地理位置、USB 设备等。如果你的网站根本用不到这些能力,那就不应该允许页面调用它们。攻击者即使拿到了 XSS 执行权限,也会发现很多能力被锁死,攻击面进一步缩小。
我常用的一套配置是直接阉割掉所有和业务无关的功能:
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), browsing-topics=()";Permissions-Policy 语法里,括号为空代表完全禁止,括号里写 self 代表只允许同源使用,写具体域名代表允许指定源使用。如果你的页面确实需要某个功能,就把它改成 self 或者对应的域名,不要全部一刀切,否则业务会出问题。
早年这个头的名字叫 Feature-Policy,后来规范改名成了 Permissions-Policy。有些老文章还在讲 Feature-Policy,注意不要抄错了。
2.6 Strict-Transport-Security(HSTS)
这个头只对 HTTPS 有意义。它告诉浏览器:在指定的时间内,只能通过 HTTPS 访问本站,不要用 HTTP,也不要在用户手动输入 http:// 时发出版本不安全的请求。
没有 HSTS 的时候,用户如果输入的是 http://,浏览器先发起一次明文请求,然后服务端 301 跳转到 HTTPS。这个过程本身存在一个中间人劫持窗口:攻击者在用户输入地址到收到跳转之间,可以插入恶意响应。
加了 HSTS 之后,浏览器在有效期内直接一步到位用 HTTPS 访问,连发 HTTP 请求的机会都不给。max-age 的秒数一般设得比较长,常见的是 15552000,也就是 180 天。includeSubDomains 表示子域名同样强制 HTTPS,subdomains 如果你确定子域都支持 HTTPS,可以加。preload 则是申请浏览器预加载列表时需要带上的标记。
add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always;注意:HSTS 只在 HTTPS 响应里生效,如果你整站都是 HTTP,这个头加了也白加。另外,一旦浏览器记住了 HSTS 策略,你临时想切回 HTTP 调试会很痛苦,所以小范围测试时可以把 max-age 调小一点。
2.7 Content-Security-Policy(CSP)
CSP 是所有这些头里块头最大、配置最复杂的一个,但也是能效最强的一个。它允许你明确告诉浏览器:页面允许加载哪些来源的脚本、样式、图片、字体、连接,禁止哪些行为。
一个典型的配置长这样:
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'" always;这个配置的意思是:所有资源默认只允许从同源加载;脚本允许同源以及内联脚本;样式允许同源以及内联样式;图片允许同源以及 data: URI;字体只允许同源;网络请求只允许同源。
CSP 最大的价值在于:即使攻击者成功注入了恶意脚本,只要你的 script-src 不允许外部域名,他的脚本要么加载不出来,要么执行不了,等于把 XSS 的伤害降到了最低。
但 CSP 也是最容易误伤业务的头。内联脚本被禁、外部 CDN 脚本被禁、动态生成的 style 属性被禁,随便哪个都能让你页面瞬间白屏。所以配置 CSP 之前,先把站点的资源来源全部梳理一遍,再逐条收敛策略。
不要在没做充分测试的情况下直接上全套 CSP,先一步一步加,看报错,再调整。
2.8 Cross-Origin-Embedder-Policy / Cross-Origin-Opener-Policy
这两个头是从 COOP 和 COEP 组合来的,主要处理跨源隔离问题。简单解释一下:Cross-Origin-Opener-Policy 控制的是你的页面在打开另一个 Window 时,和那个窗口之间的关联关系;Cross-Origin-Embedder-Policy 控制的是页面是否可以加载跨源的资源,以及这些资源是否需要显式地被授权。
add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Embedder-Policy "require-corp" always;这个组合会把你的页面置入一个“跨源隔离”的状态,只有明确声明了 CORP 头的资源才能被加载。好处是可以启用一些高性能浏览器特性,比如 SharedArrayBuffer。坏处是,如果你的页面加载了不少第三方资源,而这些资源没有配合加 CORP 头,页面会直接加载失败。
所以这两个头我不建议无脑上。只有在确实需要用 SharedArrayBuffer 之类的强隔离特性,或者在做安全级别要求非常高的内部系统时,才考虑开启。
3. 从安全头到安全配置
安全头虽然好看,但它只是 Nginx 安全配置里的一个维度。我这里把和它配合密切的几个配置一起理一理,因为它们本质上是在解决同一类问题:让响应更严格、让连接更可信、让暴露面更小。
3.1 关闭服务器版本号
Nginx 默认会在响应头里带上版本号,形如 Server: nginx/1.18.0。版本号一旦暴露,攻击者就可以对照已知的漏洞库去尝试利用,比如某个版本存在的解析漏洞或者越权读取问题。
关闭的方式很简单,在配置文件里加一行:
server_tokens off;这样响应头只会显示 Server: nginx,不透露具体版本。已经踩在坑里的老版本也可以减少被识别的概率。
3.2 禁用不必要的请求方法
默认配置下,Nginx 支持 GET、HEAD、POST、PUT、DELETE、OPTIONS这些方法。如果你的业务只是展示内容,完全不需要 PUT 和 DELETE,就应该把它们挡在门外。
if ($request_method !~ ^(GET|HEAD|POST)$) { return 405; }这个 if 用在 location 或 server 块里都可以。405 比 403 更合适,因为语义上是“方法不被允许”,而不是“身份不被认可”。
3.3 限制上传大小
控制 client_max_body_size 可以防止恶意请求用超大报文打内存。不需要接收文件上传的接口,直接设一个小值:
client_max_body_size 1m;需要上传文件的接口再单独放大,比如 50m 或者 100m。这里的关键是不要全局放开,要有收有放。
3.4 超时时间收紧
反向代理场景里,代理超时时间设置不合理,容易被慢速攻击拖住连接。核心参数有这几个:
proxy_connect_timeout 5s; proxy_read_timeout 10s; proxy_send_timeout 10s;这几个值可以根据业务接口的耗时情况做微调,但不要设成 60s 起步的大数值。连接保持时间也不要太长,keepalive_timeout 控制在 30s 以内比较稳妥。
4. 实操记录:完整配置模板
前面讲的这些头,放在一起组合成一个可直接用的 Nginx server 块配置。我自己在内部项目上用的模板大概是这样的:
server { listen 443 ssl http2; server_name your-domain.com; ssl_certificate /etc/nginx/ssl/fullchain.pem; ssl_certificate_key /etc/nginx/ssl/privkey.pem; server_tokens off; # 安全头 add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), browsing-topics=()"; add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'" always; # 请求方法限制 if ($request_method !~ ^(GET|HEAD|POST)$) { return 405; } # 请求体大小限制 client_max_body_size 2m; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 5s; proxy_read_timeout 10s; proxy_send_timeout 10s; } }| 类型 | 安全头 | 配置位置 | 生效条件 | 作用 |
|---|---|---|---|---|
| 基础 | X-Content-Type-Options | server 块 | 全部响应 | 禁止 MIME 猜测 |
| 基础 | X-Frame-Options | server 块 | 全部响应 | 防点击劫持 |
| 基础 | X-XSS-Protection | server 块 | 全部响应 | 老浏览器 XSS 过滤 |
| 基础 | Referrer-Policy | server 块 | 全部响应 | 控制来源信息泄露 |
| 能力限制 | Permissions-Policy | server 块 | 全部响应 | 禁用浏览器敏感功能 |
| 传输安全 | HSTS | server 块 | HTTPS 响应 | 强制 HTTPS 访问 |
| 内容策略 | CSP | server 块 | 全部响应 | 锁定资源来源 |
这个模板配置可以直接粘到你的配置里,改一下域名和后端地址就能用。至于 COOP / COEP,建议按需开启,不要纳入默认模板。
5. 常见问题与排查技巧实录
5.1 配置了 add_header 但不生效
最典型的原因是 add_header 的作用域覆盖问题。Nginx 有个规则:如果当前层级的块里出现过 add_header,那么上一层的 add_header 全部失效;如果没有出现过,则继承上一层的。
也就是说,你在 server 块定义了 add_header,然后在某个 location 里又写了一个 add_header,那么这个 location 里只会带上它自己写的那一个,server 层的全部被覆盖。
解决办法有两个:要么把公共的 add_header 复制一份到 location 里,要么把需要覆盖的头单独放一层,其他全部放到 location 内部。
5.2 CSP 导致页面白屏
最常见的是 script-src 只写了 'self',但页面里用了内联脚本,或者引用了外部 CDN。浏览器控制台会告诉你哪个资源被拒绝了,报错信息形如 Refused to load the script ... because it violates the following Content Security Policy directive。
排查思路:先把 CSP 头去掉,确认页面正常;再按 script-src、style-src、img-src、connect-src 逐项放开;每放开一项,刷新页面看是否恢复;找到罪魁祸首之后,针对那个域名加到对应指令的白名单里。
这里要强调的是,CSP 设计成白名单不是用来刁难开发的,而是用“默认全禁,按需放行”的思路来收敛风险。宁可配置繁琐一点,也比裸奔要强。
5.3 HSTS 开启后没法用 HTTP 调试
如果你设置了 max-age=15552000,浏览器会在半年内记住这个强制 HTTPS 策略,你想在本机用 http:// 访问调试,会被浏览器直接拦下来。
解决方式:开发机上的浏览器单独关掉 HSTS,或者用不同的域名、不同的浏览器配置文件。Chrome 里可以访问 chrome://net-internals/#hsts,在 Delete domain security policies 输入框里删掉对应域名。
所以我在新环境里测试 HSTS 时,第一次会故意把 max-age 设成 60 秒左右,验证没问题再改成长期值。这样如果配置有问题,最多一分钟就能恢复。
5.4 检查安全头是否生效
无脑且可靠的方式是用浏览器开发者工具的 Network 面板,选中一个请求,查看 Response Headers 里有没有对应的头。一个一个对,缺哪个补哪个。
批量检查可以用 curl:
curl -I https://your-domain.com输出的响应头一目了然。如果你需要扫描全站,也可以写个简单脚本,遍历主要路径逐一检查。
5.5 如果业务里有第三方接口
第三方接口一般会出现在两个场景:前端页面直接调第三方接口,或者后端代理第三方接口。前端直接调第三方接口时,CSP 的 connect-src 必须把第三方域名加进去,否则请求会被浏览器拦截。后端代理的情况下,CSP 不用改,因为浏览器看到的请求始终是同源的,由后端代发。
这个区别经常被忽略。加 CSP 的时候,先确认业务里的第三方请求是前后端直连还是后端代发,再决定 connect-src 要不要放行。
6. 最后再聊几句实操体会
配置安全头这件事,本身没有难度,难的是搞清楚场景和取舍。我见过一些人把网上的安全头模板一梭子全粘上去,结果上线当天页面白屏、第三方小部件全挂,最后灰溜溜全部回滚。也见过一些人只加了一个 HSTS 就觉得自己安全了,其实一堆基础配置还裸着。
我的建议是,分三步走:第一步先把 X-Content-Type-Options、X-Frame-Options、Referrer-Policy 这些不会影响业务的头加上去,几乎零风险;第二步关闭版本号、限制方法、限制上传大小、收紧超时时间;最后再根据自己的业务形态逐步引入 CSP 和 Permissions-Policy。每一层都要在测试环境验证过再上生产。
我个人在实际操作中最深的体会是,安全头不是配完就完事的。业务在演进,会新增 CDN 域名、会引入新的第三方 SDK、会开放新的接口路径,每次调整都要回头看 CSP 和 Permissions-Policy 是否需要同步更新。把这些检查和发布流程绑在一起,形成习惯就不会出大问题。你自己的站点从今天开始,先加上最基础的四个头,跑两天观察一下有没有异常,大概率你会发现,一切照常,但扫描报告已经好看了不少。