五个安全响应头怎么配:CSP、HSTS、X-Frame-Options 与上线前的自检顺序

安全头的价值不在数量,而在顺序。先用 Report-Only 收集违规,再切强制,最后才碰 HSTS;preload 是唯一基本不可撤回的配置。

约 10 分钟读完 · 更新于 2026-10-21 · 文中每条都能在下面的配套工具里动手验证

顺序比覆盖更重要

安全响应头的价值不在「配了多少个」,而在「按什么顺序上」。顺序错了,轻则页面白屏、接口全挂,重则域名被锁在浏览器的 HSTS preload 列表里几个月动不了。合理的落地节奏是:先开 Content-Security-Policy-Report-Only 收集一到两周违规,同时上 nosniff、Referrer-Policy、Permissions-Policy 这类没有兼容性风险的头部;确认报告干净后再把 CSP 切成强制模式;HSTS 放在最后,先给一个很短的 max-age 灰度,再逐步加到两年;preload 单独评估,因为它几乎是单向门。这套顺序的核心逻辑是:可观测的先上,不可撤回的后上。

五个头分别挡什么

响应头防什么推荐值常见误配
Content-Security-PolicyXSS、数据外带、点击劫持default-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests带 'unsafe-inline',内联脚本照跑,XSS 防护形同虚设
Strict-Transport-SecuritySSL 剥离、明文降级max-age=63072000; includeSubDomains; preload先提交 preload,之后才发现某子域只有 HTTP
X-Content-Type-OptionsMIME 嗅探导致的脚本执行nosniff只写 nosniff,但 Content-Type 本身标错,仍按错误类型解析
Referrer-Policy跨站 Referer 泄露路径与查询参数strict-origin-when-cross-origin写成 unsafe-url 或 no-referrer-when-downgrade,等于不设防
Permissions-Policy不需要的摄像头、麦克风、定位、传感器camera=(), microphone=(), geolocation=()沿用已废弃的 Feature-Policy 语法,浏览器直接忽略

nginx 里的一次性写法

nginx.conf
server {
  # always 让 4xx/5xx 响应也带上这些头
  add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://cloudflareinsights.com; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; upgrade-insecure-requests" always;
  add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
  add_header X-Content-Type-Options "nosniff" always;
  add_header Referrer-Policy "strict-origin-when-cross-origin" always;
  add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
}

# 注意:只要 location 块里写了任意一条 add_header,
# 父级 server 块的所有 add_header 都会被丢弃,必须重复声明。

CSP 的兜底与那个致命的 'unsafe-inline'

default-src 是兜底:没有单独声明的资源类型全部回落到它。script-src 管脚本,是 XSS 防护的核心;style-src 常被放宽到 'unsafe-inline',因为很多组件库运行时注入内联样式,这是可以接受的妥协,但要清楚它是妥协;connect-src 管 fetch、XMLHttpRequest、WebSocket 和 EventSource,漏配的表现是接口请求被浏览器拦下,Network 面板里状态显示 blocked,很容易误判成后端故障;frame-ancestors 用来替代 X-Frame-Options 防点击劫持;upgrade-insecure-requests 把页面里的 http 子资源自动升级为 https,是处理混合内容最省事的一条。真正要警惕的是把 'unsafe-inline' 加进 script-src:浏览器从此不再拦截任何内联脚本,攻击者注入的脚本标签照常执行,CSP 剩下的只是「限制能连哪些域名」。要做严格的脚本控制,走 nonce 或 hash:每次响应生成随机 nonce,写进 script 标签和 script-src,静态站点做不到动态 nonce 就用 hash 或全量外链。还有一个实践细节:CSP 对内联事件属性的处理和内联 script 完全相同,都归 unsafe-inline 管。老项目一开强制 CSP,整站按钮集体失效,基本都是这个原因,而控制台只会输出一行拒绝执行内联事件的提示,很容易被误判成框架问题。

HSTS:三件套与一扇单向门

Strict-Transport-Security 有三个组成部分。max-age 单位是秒,63072000 等于两年,它告诉浏览器在接下来这段时间内对该域名一律走 HTTPS;includeSubDomains 把规则套用到所有子域,上这条之前必须确认没有任何子域只跑 HTTP,包括早已废弃的测试域名;preload 是向浏览器内置列表提交申请,一旦通过,用户在拿到任何响应之前就会被强制走 HTTPS,连第一次明文请求的机会都没有。撤销可以提交,但要等浏览器发新版才生效,周期以月计,期间所有纯 HTTP 地址对这部分用户彻底不可达。这是所有响应头里唯一基本不可撤回的一个,所以顺序必须是:先 300 秒灰度,验证全站与子域,再逐步加到一年、两年,最后才讨论 preload。另外,HSTS 只在 HTTPS 响应上被接受,通过 HTTP 下发的这个头会被浏览器直接忽略。所以部署顺序必须是先装证书、确认全站可以用 https 打开,再下发这个头;反过来做不会有效果,也测不出问题。

两阶段切换 CSP

第一阶段:只报告
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; report-uri /csp-report

# 违规不会拦截,只在控制台打印并 POST 到 /csp-report
# 报告体形如 {"csp-report":{"violated-directive":"script-src","blocked-uri":"https://cdn.example.com/a.js"}}
# 新版浏览器还支持 report-to,需要先用 Report-To 头声明分组
第二阶段:切强制
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; upgrade-insecure-requests

# 保留 report-uri 一段时间,强制模式下仍会收到真实违规
# 上线后 24 小时内重点盯 4xx 页面和登录、支付、上传三条主链路

一个真实案例:照抄的 CSP 一定会出事

本站上线时用了一份流传很广的 CSP 模板,页面能打开,功能也基本正常,但控制台稳定刷出 4 条 CSP 违规:Cloudflare 自动注入的 Web 分析脚本来自 static.cloudflareinsights.com,被 script-src 拦下;它采集到的数据要发往 cloudflareinsights.com,又被 connect-src 拦下。两条来源、两类指令,正好四条报错。补上这两处白名单之后才彻底安静。这个案例的结论很硬:CSP 必须按自己页面实际加载的资源清单生成,照抄别人的配置必然出事,因为别人的白名单里没有你的第三方脚本,而你的白名单里塞满了你用不到的域名——每多一个域名,就多一条可被利用的外带通道。顺带提醒,静态资源域名、字体域名、图片 CDN、错误监控 SDK 的上报域名,这四类最容易被漏掉。现在这份白名单跟着第三方脚本清单一并维护:每接入一个新的 SDK 或统计工具,先在 Report-Only 模式跑一天,把新出现的 blocked-uri 补进对应指令,再切回强制模式。这个流程比凭记忆维护域名可靠得多。

上线前的自检顺序

  • 先用 Report-Only 跑一到两周,覆盖登录、支付、上传、错误页这几条路径,收集完整违规清单再动手。
  • 先上零风险三件套:X-Content-Type-Options: nosniff、Referrer-Policy: strict-origin-when-cross-origin、Permissions-Policy 关掉不需要的能力。
  • CSP 转强制前确认 script-src 里没有 'unsafe-inline';有就换 nonce 或 hash,这一步不做,前面的工作意义有限。
  • 检查 X-Frame-Options 与 frame-ancestors 是否表达同一个策略,不一致时按 CSP 的结果为准,别以为两套都生效。
  • HSTS 的 max-age 从 300 秒开始,逐级放大;includeSubDomains 上线前把子域清单拉出来逐个验证 HTTPS。
  • preload 单独决策:确认所有子域支持 HTTPS、短期内不打算回退,再提交,提交后把这件事写进交接文档。

最后一句

安全头是纵深防御里的一层,不是替代品。它挡不住服务端把未过滤的输入拼进 SQL,也挡不住 token 被写进 URL 再被日志采集。把它当成降低一类攻击成功率的工程手段,比当成合规勾选项实际得多。能用五分钟配好的东西,也值得每季度复查一次,因为第三方脚本会变,白名单不会自己更新。

常见问题

CSP 里用了 'unsafe-inline' 还有意义吗?

对 XSS 基本失效,只剩 frame-ancestors 的约束和报告价值,需改用 nonce 或 hash。

HSTS 的 preload 能不能取消?

可以提交移除申请,但要等浏览器发版才生效,周期以月计,期间用户无法访问纯 HTTP 地址。这就是它被称为不可撤回的原因。

X-Frame-Options 和 frame-ancestors 冲突时听谁的?

支持 CSP Level 2 的浏览器忽略 X-Frame-Options,只按 frame-ancestors 执行;很老的浏览器则相反。两者表达不一致会得到割裂的行为。

为什么加了 CSP 后页面样式全乱但报错不多?

大概率是 style-src 拦了内联样式,控制台只会折叠成一条 Refused to apply inline style。用 Report-Only 再跑一遍,能一次性收全所有违规。

📂

松开以自动格式化

支持 .json / .sql / .csv / .html / .css / .xml / .yaml 文件