约 10 分钟读完 · 更新于 2026-10-21 · 文中每条都能在下面的配套工具里动手验证
安全响应头的价值不在「配了多少个」,而在「按什么顺序上」。顺序错了,轻则页面白屏、接口全挂,重则域名被锁在浏览器的 HSTS preload 列表里几个月动不了。合理的落地节奏是:先开 Content-Security-Policy-Report-Only 收集一到两周违规,同时上 nosniff、Referrer-Policy、Permissions-Policy 这类没有兼容性风险的头部;确认报告干净后再把 CSP 切成强制模式;HSTS 放在最后,先给一个很短的 max-age 灰度,再逐步加到两年;preload 单独评估,因为它几乎是单向门。这套顺序的核心逻辑是:可观测的先上,不可撤回的后上。
| 响应头 | 防什么 | 推荐值 | 常见误配 |
|---|---|---|---|
| Content-Security-Policy | XSS、数据外带、点击劫持 | default-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests | 带 'unsafe-inline',内联脚本照跑,XSS 防护形同虚设 |
| Strict-Transport-Security | SSL 剥离、明文降级 | max-age=63072000; includeSubDomains; preload | 先提交 preload,之后才发现某子域只有 HTTP |
| X-Content-Type-Options | MIME 嗅探导致的脚本执行 | 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 语法,浏览器直接忽略 |
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 都会被丢弃,必须重复声明。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,整站按钮集体失效,基本都是这个原因,而控制台只会输出一行拒绝执行内联事件的提示,很容易被误判成框架问题。
Strict-Transport-Security 有三个组成部分。max-age 单位是秒,63072000 等于两年,它告诉浏览器在接下来这段时间内对该域名一律走 HTTPS;includeSubDomains 把规则套用到所有子域,上这条之前必须确认没有任何子域只跑 HTTP,包括早已废弃的测试域名;preload 是向浏览器内置列表提交申请,一旦通过,用户在拿到任何响应之前就会被强制走 HTTPS,连第一次明文请求的机会都没有。撤销可以提交,但要等浏览器发新版才生效,周期以月计,期间所有纯 HTTP 地址对这部分用户彻底不可达。这是所有响应头里唯一基本不可撤回的一个,所以顺序必须是:先 300 秒灰度,验证全站与子域,再逐步加到一年、两年,最后才讨论 preload。另外,HSTS 只在 HTTPS 响应上被接受,通过 HTTP 下发的这个头会被浏览器直接忽略。所以部署顺序必须是先装证书、确认全站可以用 https 打开,再下发这个头;反过来做不会有效果,也测不出问题。
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 模板,页面能打开,功能也基本正常,但控制台稳定刷出 4 条 CSP 违规:Cloudflare 自动注入的 Web 分析脚本来自 static.cloudflareinsights.com,被 script-src 拦下;它采集到的数据要发往 cloudflareinsights.com,又被 connect-src 拦下。两条来源、两类指令,正好四条报错。补上这两处白名单之后才彻底安静。这个案例的结论很硬:CSP 必须按自己页面实际加载的资源清单生成,照抄别人的配置必然出事,因为别人的白名单里没有你的第三方脚本,而你的白名单里塞满了你用不到的域名——每多一个域名,就多一条可被利用的外带通道。顺带提醒,静态资源域名、字体域名、图片 CDN、错误监控 SDK 的上报域名,这四类最容易被漏掉。现在这份白名单跟着第三方脚本清单一并维护:每接入一个新的 SDK 或统计工具,先在 Report-Only 模式跑一天,把新出现的 blocked-uri 补进对应指令,再切回强制模式。这个流程比凭记忆维护域名可靠得多。
安全头是纵深防御里的一层,不是替代品。它挡不住服务端把未过滤的输入拼进 SQL,也挡不住 token 被写进 URL 再被日志采集。把它当成降低一类攻击成功率的工程手段,比当成合规勾选项实际得多。能用五分钟配好的东西,也值得每季度复查一次,因为第三方脚本会变,白名单不会自己更新。
对 XSS 基本失效,只剩 frame-ancestors 的约束和报告价值,需改用 nonce 或 hash。
可以提交移除申请,但要等浏览器发版才生效,周期以月计,期间用户无法访问纯 HTTP 地址。这就是它被称为不可撤回的原因。
支持 CSP Level 2 的浏览器忽略 X-Frame-Options,只按 frame-ancestors 执行;很老的浏览器则相反。两者表达不一致会得到割裂的行为。
大概率是 style-src 拦了内联样式,控制台只会折叠成一条 Refused to apply inline style。用 Report-Only 再跑一遍,能一次性收全所有违规。