Nginx安全头配置实战:从HTTP响应头到CSP与HSTS的加固指南
前几年我接手过一批对外服务的站点安全扫描报告里“安全头”一栏几乎全是红叉。那会儿对概念不熟总觉得这些头不疼不痒业务功能正常就行少几个响应头能出多大的事。后来在一次跨域窃取用户信息的排查里才发现某个页面就是因为少了限制加载来源的响应头被嵌进了攻击者构造的页面用户手里的会话凭证差点被顺走。从那次之后我把 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-OptionsSAMEORIGIN防止页面被嵌入第三方 iframe拦截点击劫持X-Content-Type-Optionsnosniff禁止浏览器猜测响应类型降低 MIME 混淆攻击风险Referrer-Policystrict-origin-when-cross-origin限制请求来源信息泄露避免 URL 参数被带到外部站点Permissions-Policygeolocation(), microphone(), camera()默认禁用浏览器高级权限接口Strict-Transport-Securitymax-age31536000; 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-age31536000; 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-age31536000; 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 可以直接用 DENYCSP 也可以大幅收缩甚至可以只保留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-age403、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 这几个最能挡住实际风险的头先配稳比堆十几个头却样样失效要可靠得多。