前几年我接手过一批对外服务的站点,安全扫描报告里“安全头”一栏几乎全是红叉。那会儿对概念不熟,总觉得这些头不疼不痒:业务功能正常就行,少几个响应头能出多大的事。后来在一次跨域窃取用户信息的排查里,才发现某个页面就是因为少了限制加载来源的响应头,被嵌进了攻击者构造的页面,用户手里的会话凭证差点被顺走。从那次之后,我把 Nginx 常用安全头当成一项基础加固来对待,配置量不大,却能在浏览器这一层拦住不少常见攻击。
这篇文章就围绕 Nginx 常用安全头展开,把 X-Frame-Options、Content-Security-Policy、Strict-Transport-Security 这些头的作用写明白,Nginx 里的写法、继承规则、不同业务场景下的调整方案也都会覆盖。适合手里维护着 Nginx 配置、想快速提升站点安全性的同学,也适合刚入门、还没搞懂 add_header 和 always 之间区别的读者。
1. 安全头到底在解决什么问题
HTTP 响应头是服务器和浏览器之间的“合同文本”。你返回的 HTML 决定页面长什么样,响应头里附加的字段则决定浏览器应该用哪种方式来解释和执行页面。安全头就是这一类指令,告诉浏览器哪些行为允许,哪些行为需要阻止。
1.1 浏览器才是最终执行者
很多防护工作是在服务端完成的,比如过滤输入参数、校验上传文件。但页面最终被解释执行的地方是浏览器,你不可能在每个客户端机器上装一套防护插件,能做的只有通过响应头向浏览器下达明确的策略。举个生活化的例子:小区门口有保安,但你自己家里的门锁也得装好。应用层的过滤像保安,它能把大部分可疑人员挡在外面,可一旦有人进到了房间里,安全头就是那道门锁,让恶意脚本没法随意打开窗户、翻抽屉。
这种防护机制有一个显著优点,就是它不依赖应用层代码是否及时修了漏洞。哪怕你的业务代码某天被注入了一段脚本,严格的内容安全策略也能让浏览器拒绝执行这段脚本。对运维同学来说,这是用纯配置换回一层额外保险,很划算。
1.2 一份最小安全头的效果
我最早做安全加固时,用的是一套极简组合,效果却出乎意料地好:
| 响应头 | 建议值 | 主要作用 |
|---|---|---|
| X-Frame-Options | SAMEORIGIN | 防止页面被嵌入第三方 iframe,拦截点击劫持 |
| X-Content-Type-Options | nosniff | 禁止浏览器猜测响应类型,降低 MIME 混淆攻击风险 |
| Referrer-Policy | strict-origin-when-cross-origin | 限制请求来源信息泄露,避免 URL 参数被带到外部站点 |
| Permissions-Policy | geolocation=(), microphone=(), camera=() | 默认禁用浏览器高级权限接口 |
| Strict-Transport-Security | max-age=31536000; includeSubDomains | 强制浏览器通过 HTTPS 访问,削弱降级攻击 |
这套配置在 Nginx 层面加上之后,再跑一轮扫描,红叉基本清了,只有部分扫描器还会单独揪着 Content-Security-Policy 不放。那是一个工作量更大的头,也最需要按站点实际情况来调,后面会单独说。
2. 常用安全响应头逐一拆解
很多人一上来就找 Nginx 配置模板,复制粘贴完就去跑测试,结果发现页面样式乱了、第三方登录地址丢了来源信息,然后开始怀疑模板有问题。问题往往出在没搞懂每个头的语义。把每个头当成一个独立策略来看,配置起来会顺手很多。
2.1 X-Frame-Options:最简单也最容易漏掉的点击劫持防护
点击劫持攻击的核心思路是:攻击者把目标网站悄悄嵌进自己页面的透明 iframe 里,诱导用户点击。用户以为自己点的是攻击者页面的按钮,实际点中的是目标页面的操作按钮。如果没有这个控制项,这类攻击在浏览器层面几乎毫无阻力。
X-Frame-Options 有三个可选值:
- DENY:无论哪个站点,都禁止用 iframe 嵌入。
- SAMEORIGIN:只允许同源页面嵌入自己。
- ALLOW-FROM:指定一个来源可以嵌入。这个值兼容性较差,基本已经不太建议使用。
日常业务站点一般用 SAMEORIGIN 就够了。如果一个管理后台完全不需要被别人嵌入,可以直接 DENY。需要注意,新版本浏览器更推荐用 CSP 的 frame-ancestors 指令来控制嵌套加载,而且当两者出现冲突时,支持 CSP 的浏览器会优先采信frame-ancestors。所以稳妥的做法是两者都写,由老浏览器认 X-Frame-Options,新浏览器认 CSP 里的frame-ancestors。
2.2 Content-Security-Policy:安全头里的重武器
Content-Security-Policy,也就是常说的 CSP,是整个安全头体系里控制面最广、也最需要花心思的一项。它通过一组指令告诉浏览器:页面里哪些来源的脚本、样式、图片、连接是允许的。一旦某个来源不在白名单里,浏览器会选择拦截,而不是继续执行。
一份生产项目中比较常见的 CSP 长这样:
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' wss://api.example.com; object-src 'none'; frame-ancestors 'self'; form-action 'self'; upgrade-insecure-requests逐条翻译一下:
- default-src:没有单独指定的资源类型时,统一使用这里的默认来源。
- script-src:控制 JavaScript 来源。写
'self'只允许同域脚本,禁止第三方脚本。 - style-src:控制样式来源。很多项目保留
'unsafe-inline',是因为页面上有内联 style 属性。 - img-src:图片来源。
data:允许 base64 内联图片。 - connect-src:控制 XHR、WebSocket 等请求地址。
- object-src:控制 plugin 类资源,建议直接设
'none',现在很少有业务需要活控件。 - frame-ancestors:允许哪些源可以将页面嵌入 iframe。
- form-action:限制表单的提交地址,防止表单数据被悄悄提交到第三方。
- upgrade-insecure-requests:自动把页面里的 HTTP 请求升级成 HTTPS。
配置 CSP 最容易犯的错是开局就把所有指令写得太宽,比如为了省事在 script-src 里加上'unsafe-inline'。这会直接削弱 CSP 对 XSS 的拦截价值。内联脚本不是不能用,但需要使用 nonce 机制来准确放行,这里很多团队容易走弯路。
Nginx 并不是无法生成 nonce,只是要做到头里的随机值同页面 script 标签中的 random 值保持一致,需要额外用 sub_filter 或者在发现中做协作,成本和维护复杂度都会上去。如果项目还没到必须要用内联脚本那一步,我通常会先建议把内联逻辑抽出来,放在现有站点里作为独占文件,既简单又安全。
2.3 Strict-Transport-Security:强制走 HTTPS 的保险丝
Strict-Transport-Security 的缩写是 HSTS,作用是告诉浏览器:接下来一段时间内,只允许用 HTTPS 访问当前域名。浏览器收到这个头后,会记住这条策略,即使你在地址栏里手输入了 HTTP 链接,浏览器也会先自动改写为 HTTPS 再发起请求。
这一特性可以很好防住 SSL 剥离攻击。攻击者如果截获了你的 HTTP 请求,并且篡改页面里的链接为 HTTP,或中间那个家伙通常能拦截到这次访问。只要浏览器里提前记住了 HSTS 规则,这个过程会被阻止。
Nginx 配置示例:
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;这里有两个字段很关键:
- max-age:浏览器记住策略的秒数,31536000 是一年。
- includeSubDomains:是否对子域名同样有效。
我建议谨慎对待 includeSubDomains。如果某一个子域名还没启用 HTTPS,或者未来有临时迁移到 HTTP 的计划,加了这个字段后,浏览器会拒绝通过普通 HTTP 访问那个子域名,只能走 HTTPS,反过来影响可用性。稳妥做法是先只配 max-age 并保持较短的时长,比如 300 秒,观察几天确认站点稳定走 HTTPS 后,再把时间拉长。HSTS 还有一个前置条件:response 必须是通过 HTTPS 发送的,浏览器才会理会。如果通过 HTTP 直接访问,这个头会被忽略。
2.4 X-Content-Type-Options、Referrer-Policy、Permissions-Policy 三个小头
这三个头不像 CSP 那么复杂,但起着非常重要的补充作用。
X-Content-Type-Options 只支持一个值nosniff,用来禁止 MIME 嗅探。举个例子,你的站点允许用户上传图片,正常情况下返回Content-Type: image/png。如果没有nosniff,某些浏览器遇到返回类型和实际内容不一致时,会试图“猜”文件的真实类型。如果用户实际上传的是一个伪装成图片的 HTML 文件,浏览器一猜就直接把它按 HTML 执行了,存储型 XSS 就这样产生。加上nosniff之后,浏览器会严格按照 Content-Type 解析,不再猜测。
Referrer-Policy 控制的是页面跳转时,浏览器在请求头 Referer 里带走多少来源信息。如果不限制,用户从你的带参数页面跳去外部站点,外部站点就能看到带 token 或内部参数的完整 URL,这是很多信息泄露事故的来源。推荐值是strict-origin-when-cross-origin,含义是:同源请求带上完整地址,跨源请求只带站点源(即 scheme + 域名),从 HTTPS 降级到 HTTP 时不携带任何来源信息。
Permissions-Policy 之前叫 Feature-Policy,负责的是浏览器定位、麦克风、摄像头、通知等权限的默认开关。对于普通内容站点,通常不需要这些能力,可以直接声明禁用:
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always;它比在前端代码里写权限问询更被动也更可控,浏览器看到空括号后就知道这些 API 在本站点不被允许。
2.5 关于 X-XSS-Protection 和跨源隔离头
X-XSS-Protection 很多老教程还在推荐,需要说明一下,它原本是浏览器内置的反射型 XSS 过滤器,现代浏览器已经逐渐把它废弃或删除了,因为它的过滤逻辑本身存在被攻击者绕过甚至引入新漏洞的问题。既然它不可靠,就不应该当成主要防线。如果从某个老旧配置里继承了这个头,保留无害,但不要依赖它。真正有拦截效果的是 CSP。
另外,对于放置高级敏感数据的站点,还可以考虑 Cross-Origin-Opener-Policy 和 Cross-Origin-Embedder-Policy,这对响应头用于构建跨源隔离,隔离之后 window.opener 无法从其他源读取页面信息,Spectre 这类侧信道攻击面也会变小。但启用跨源隔离会对第三方资源有更严格要求,容易影响现有业务,所以优先级并不高,属于“有余力再上”的选项。
3. Nginx 配置实操与继承规则
响应头的“配方”再多,最终都要落到 Nginx 的配置里。这一节是实战量最大的部分,重点讲 add_header 的正确姿势,还有那个能让运维挠头的继承规则。
3.1 add_header 和 always 有什么区别
Nginx 添加响应头用的是 add_header 指令,基础语法是:
add_header 名称 值 [always];不带 always 时,Nginx 只会在 200、201、204、206、301、302、304 等正常响应的场景里添加对应头。遇到 400、401、403、404、500 这些错误状态码时,头就不会出现在响应里。可是错误页面也经常是攻击者利用的对象,而且站在安全扫描的角度,它对所有状态码的响应都会进行检查,一旦错误响应没带安全头,照样会在报告里被标红。
加上 always 之后,除了特殊情况之外的几乎所有响应都会携带指定头。平时配置安全头时,建议一律加 always,不要有悬着的漏网状态码。
3.2 配置放哪一层,那个经典的大坑
Nginx 的配置有 http、server、location 三个层级。add_header 这个指令并不是简单地从父级“继承再合并”,它只在当前层级没有出现过 add_header 时,才继承上一层的配置。一旦你在某个 location 里写了一个 add_header,那这个 location 的所有响应只会使用你自己写的这一批,http 和 server 层写的安全头全部失效。
举个例子,下面的配置是很多人踩过的坑:
server { listen 443 ssl; add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; location / { proxy_pass http://backend; add_header Cache-Control "no-store"; } }你以为 location 里多加了 Cache-Control 只是多一个头,实际却是 X-Content-Type-Options 和 X-Frame-Options 全都不再生效,因为它们被那一个 add_header 覆盖掉了。正确写法是把所有安全头在 location 里重新列一遍,或者就不要在 location 里新增 add_header,改为通过后端加缓存控制头。
在实际维护多个站点时,我习惯把安全头单独拆成一个文件,比如/etc/nginx/conf.d/security-headers.conf,在每个 server 块里通过 include 引用:
server { listen 443 ssl; include /etc/nginx/conf.d/security-headers.conf; location / { proxy_pass http://backend; } }这样做的好处是升级 CSP 策略时只需改动一个文件,所有站点同步更新,不需要打开十几个配置文件来回复制。
3.3 整套最小安全头配置示例
以一套常规的 HTTPS 站点为例,我会在 server 块里写这样一组配置:
server { listen 443 ssl; server_name example.com; 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; includeSubDomains" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; frame-ancestors 'self'; form-action 'self'; upgrade-insecure-requests" always; ... }这套配置里的 CSP 对纯业务型前端站点来说比较严格,style-src 保留了'unsafe-inline'是因为很多前端框架渲染时会把样式作为内联属性写进 DOM,完全放开会让开发阶段变得很低效。至于 script-src 只保留'self',我不建议一上来就开'unsafe-inline',这点对 XSS 的拦截效果影响很大。
配置完记得对 Nginx 做一次配置文件测试:
nginx -t然后平滑重载:
nginx -s reload4. 不同业务场景下的差异化配置
安全头用同一套模板打天下的思路,在小规模场景下没问题,但站点种类变多之后,很容易出现“头加了,业务炸了”的情况。在实际操作中,我通常会根据业务形态做拆分。
4.1 带第三方资源的营销内容站点
这类站点往往有第三方的统计脚本、广告物料、在线客服弹窗,还有外部图片和字体资源。如果严格写 script-src 'self',大概率第二天统计脚本就会失效,广告也没法展示。
一般会在 script-src 里补充统计站点和客服站点的域名白名单,比如:
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://stat.example.net https://chat.example.org; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://ad.example.net; font-src 'self' https://fonts.example.net; connect-src 'self' https://api.example.net; object-src 'none'; frame-ancestors 'self'" always;需要记住的是,CSP 白名单里每多一个域名,就等于多了一个可信来源。统计脚本本身要是被攻破,攻击代码就会顺着白名单进入页面。所以第三方脚本不要能省就省,留真正需要用的。
4.2 前后端分离或 API 服务
纯 API 服务没有页面渲染诉求,安全头可以收得非常紧。X-Frame-Options 可以直接用 DENY,CSP 也可以大幅收缩,甚至可以只保留default-src 'none'; frame-ancestors 'none',意思是页面环境下没有来源是被允许的。
同时,建议单独为跨域接口处理 OPTIONS 预检请求。预检响应本身也可以带上安全头,避免有些浏览器对预检响应里的头做特殊处理时出现不一致。Nginx 里如果用了add_header的 always,一般能照顾到这部分。
前后端分离场景下,如果后端接口返回的数据里存在未转义内容,CSP 对前端页面的保护作用会受影响,因为 CSP 主要管的是“浏览器如何执行”,而非“服务端输出是否合法”。所以即使配置了严格头,后端的数据校验和输出编码也不能省。
5. 配置后的验证方法与问题排查
大多数人加完安全头后,只是看一眼浏览器访问正常就收工。结果过几天被安全扫描追上,发现某个静态文件或者错误页没带头,又重新排查一遍。与其这样来回折腾,不如把验证步骤固定下来。
5.1 用 curl 检查响应头
最快的方法是在服务器本机或者本地开发环境用 curl 抓取响应头:
curl -sI https://example.com/ | grep -iE "content-security|x-frame|x-content|strict-transport|referrer|permissions"-sI 表示只取响应头,不发请求体。如果站点有多个典型路径,比如首页、登录页、接口、静态资源、404 页面,建议每个路径都检查一遍。对 404 页面,别忘了查看响应状态码为空或非 200 时头是否有返回,这正好能验证 always 是否生效。
还有一种隐蔽情况:如果 Nginx 后面还套了 CDN,你抓到的头可能是 CDN 层生成的,而不是源站直接生成的。这时候需要绕过 CDN,回到源站 IP 或测试环境,才能判断具体哪一层缺了头。我曾经遇到一次线上响应头重复,出现两个 X-Frame-Options,排查后发现是 CDN 在源站基础上又追加了一个,花了不少时间才确定问题出在链路层而不是 Nginx 配置。
5.2 常见故障和应对思路
把实际环境中遇到比较多的问题整理成了一张速查表:
| 现象 | 可能原因 | 应对思路 |
|---|---|---|
| 页面脚本不执行,控制台报 CSP 报错 | script-src 没有放行内联脚本或第三方域名 | 把内联逻辑外置;确认报错域名后决定是否加入白名单 |
| 图片显示不出来,CSP 报 img-src 被拦 | img-src 没加 data: 或对应图片域名 | 检查响应 URL,补充必要来源 |
| 字体图标变成方框 | font-src 没放行字体文件来源 | 补上字体 CDN 域名或 data: 来源 |
| 某个子域名突然无法通过 HTTP 访问 | HSTS 的 includeSubDomains 已生效且子域未启 HTTPS | 为子域配置 HTTPS,或在观察期内删除 includeSubDomains 并重新配置短 max-age |
| 403、404 页面没有安全头 | 未使用 always | 统一加上 always 后重载 |
| location 里的 add_header 把全局安全头覆盖 | location 内新增了 add_header,导致父级全失效 | 在 location 里补全所有安全头,或避免在该 location 使用 add_header |
| 同一个头出现两次,浏览器取哪个不确定 | 源站和 CDN 各加了一遍 | 在 CDN 或 Nginx 层移除其中一个,保留统一出处 |
排查 CSP 报错有个日常工作流:打开浏览器开发者工具,切到控制台,报错信息里会明确告诉你被拦的资源 URL 和触发了哪条指令。不要凭感觉改配置,逐个把报错的合法资源来源加进白名单,然后清缓存重新验证。
5.3 上线前容易被忽略的细节
有几点是我每次上线前都要再确认一遍的:
第一,HSTS 的 max-age 和 includeSubDomains 到底能不能一次性拉满。可以先设置一个短生效时间,比如 300 秒,放到预发布环境跑一段时间,确认所有子域都是 HTTPS 无隐患后,再增到 31536000。
第二,页面是否在使用非安全来源的资源,比如 HTTP 图片、HTTP 接口。CSP 的 upgrade-insecure-requests 指令可以把这类请求自动转成 HTTPS,但前提是服务端真的支持 HTTPS。否则会出现上游请求失败,页面看起来像被 CSP 拦截了,实际是后端不支持升级。
第三,新升级的安全头在局部路径上验证成功后,还要考虑浏览器缓存策略。浏览器对响应头的缓存时间通常受 Cache-Control 影响。验证时如果看到的是旧策略,记得加一个无缓存的请求参数或者使用隐私窗口。
6. 一些个人经验:先把清单列出来,再想优化
配置安全头并不像改业务代码那么直接,更多时候是在和“惯性”做对抗。有一回为了给一个站点加 CSP,我把所有内联脚本抽出来放到独立 JS 文件里,又调整了一版图片资源的来源策略,前后花了半天,结果就是把扫描报告从黄色提升到绿色,同时换来了页面加载更干净。这个过程中我没有一次性把 unsafe-inline 放开,而是选择在源头上把内联代码清理掉,后面再维护时就少了一块争论点。
如果你所在团队的服务不止一个,建议先整理一份“当前支持的头、建议值、备注”的表格,再把每个站点的响应头用一条命令批量拉出来做对比。这样谁缺了什么一目了然,更新的优先级也就清楚了。安全头本身的边际效益是逐步递减的,不要追求把所有选项全都拉满,把 X-Frame-Options、CSP、HSTS、nosniff 这几个最能挡住实际风险的头先配稳,比堆十几个头却样样失效要可靠得多。