如果你的页面在配置了CSP之后突然白屏,别慌,这几乎是每个前端接入 Content Security Policy 都会遇到的第一道坎。CSP 的全称是 Content Security Policy,翻译过来就是内容安全策略。它不是一个库、不是一个框架,也不是什么新东西,而是浏览器原生支持的一套安全机制,用来告诉浏览器“这个页面到底允许加载哪些资源、执行哪些脚本”。这篇文章我想从为什么需要它、指令到底怎么配、上线踩过哪些坑、怎么调试和灰度,一路聊到 nonce、strict-dynamic 这些进阶玩法,适合所有正在给 Web 应用做安全加固的前后端同学参考。
很多人第一次接触 CSP 是被安全扫描报告逼的——报告里红字写着“缺少 Content-Security-Policy 响应头”“存在跨站脚本(XSS)风险”。然后你照着网上的配置贴了一段,页面直接白屏,脚本全部被拦,于是又默默删掉。这其实不是 CSP 的问题,而是我们还没有理解它的运行逻辑。搞清楚它的核心思路之后,你会发现它既不是洪水猛兽,也不是银弹,而是一块需要持续维护的安全基石。
1. 为什么 XSS 总是防不住:CSP 要补上的最后一层防线
1.1 黑名单思维为什么救不了 XSS
XSS 的历史几乎和 Web 本身一样长。我见过很多团队在防 XSS 这件事上投入了大量精力:后端做输入过滤,前端做输出转义,Cookie 挂 HttpOnly,WAF 规则写了好几屏。但攻击者依然能找到绕过的口子,原因在于过滤和转义本质上都是“黑名单思维”——我们写了一份不允许出现的字符或标签清单,然后祈祷攻击者不会用清单之外的方式绕过去。
富文本场景是最典型的例子。你要允许用户提交一段 HTML,但又不允许里面有 script 标签、事件属性、javascript: 伪协议。这个边界光靠正则几乎不可能守住,因为 HTML 的解析规则比大多数人想象的复杂得多。今天你堵住了<svg onload=...>,明天还有<math>、<iframe>、<video poster=...>,甚至某些浏览器独有的解析歧义可以被用来构造绕过。DOM-based XSS 更麻烦,漏洞不出现在服务端的字符串拼接里,而是出现在前端 document.write、innerHTML、location 跳转这些运行时操作上,服务端根本无从过滤。所以不管代码评审多严格,总会有漏网之鱼。
1.2 CSP 的默认拒绝模型:把“不确定”一律当作“不允许”
CSP 换了一个思路:它不再去判断哪段代码是恶意的,而是直接声明“这个页面只允许执行来自这些来源的代码”,凡是没在名单里的,哪怕它看起来再正常,也一律拒绝执行。这个模型在安全领域叫“默认拒绝”,是白名单思路的典型应用。
我经常用一个类比:以前的小区保安是盯着每个进大门的人,凭经验判断他像不像坏人,这是黑名单;CSP 相当于给小区装了门禁系统,只认门禁卡,没有卡的谁也进不来,不管你是好人还是坏人。脚本执行、样式加载、图片资源、网络请求、iframe 嵌入、表单提交,每一类资源都可以单独设置来源。这意味着即使你的站点真的有 XSS 漏洞,攻击者注入的脚本也没有可执行的土壤——浏览器在最前面就把它拦下了。
要强调的是,CSP 不是用来替代转义和过滤的。转义和过滤负责减少漏洞,CSP 负责兜底。如果有一天某个漏洞从你没注意到的角落溜了进来,CSP 能保证攻击者没法直接在页面上执行 JavaScript。它是一道纵深防御的城门,不是哨兵。
2. CSP 指令全景:从 default-src 到 frame-ancestors 逐个说透
2.1 一张表看懂常用指令
CSP 的配置全部写在响应头里,形式是一组指令加值,分号分隔。第一次看到完整配置的人往往会懵,其实每个指令控制的资源类型非常清晰。
| 指令 | 控制范围 |
|---|---|
| default-src | 兜底策略,其他指令没写时用它 |
| script-src | 脚本可以执行的来源 |
| style-src | 样式表可以加载的来源 |
| img-src | 图片、favicon 等图像资源 |
| connect-src | fetch、XHR、WebSocket、EventSource 等请求 |
| font-src | 字体文件来源 |
| media-src | audio 和 video 标签的来源 |
| frame-src | iframe 可以嵌入的页面地址 |
| object-src | object、embed、applet 等插件资源 |
| base-uri | 页面中<base>标签可以使用的地址 |
| form-action | 表单可以提交到的地址 |
| frame-ancestors | 允许哪些页面用 iframe 嵌入当前页面 |
| upgrade-insecure-requests | 自动把页面内 HTTP 请求升级为 HTTPS |
default-src 的逻辑需要单独说一下。如果你没有单独写 script-src,但写了 default-src,那么脚本来源会继承 default-src 的值。所以绝大多数配置都会先写一条default-src 'self'作为基准线,再单独收紧或放宽某类资源。这种“先堵死再开门”的顺序比逐条列来源更安全,因为你永远不知道漏写了哪类资源。
2.2 一份可以改着用的生产配置
下面是我在实际项目里常用的一份起步配置,Nginx 响应头写法如下:
add_header Content-Security-Policy " default-src 'self'; script-src 'self' https://static.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://img.example.com; connect-src 'self' https://api.example.com wss://ws.example.com; font-src 'self' https://fonts.example.com; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests; ";逐行解释一下为什么这么写:
default-src 'self'是底线,所有没单独列出的资源默认只允许来自本站域名。script-src 'self' https://static.example.com只信任本站和静态资源 CDN 的脚本。这里没有加 unsafe-inline,意味着页面里所有内联 script 都会被浏览器拦截——这正是我们想要的,但是也意味着你的代码里不能有内联脚本,后面我会专门讲怎么处理。style-src 'self' 'unsafe-inline'放行了内联样式。这个决定被很多安全教程批评,但实际项目里几乎绕不开:很多组件库和框架会往 DOM 里插<style>标签,完全禁掉内联样式,你连一个 UI 组件都跑不起来。img-src 'self' data:允许 base64 图片,因为现在很多小图标都是 data URI 内嵌的,这是成本低廉的妥协。connect-src一定要带上你所有的 API 域名和 WebSocket 地址,且 WebSocket 必须用 wss:// 前缀,否则页面里的 fetch 会莫名失败。object-src 'none'直接禁掉一切插件,Java applet、Flash 这种早就该入土的玩意儿没必要开任何口子。frame-ancestors 'none'禁止任何页面用 iframe 嵌入你的站点,防止点击劫持。注意这个指令不需要写'self',因为它不反馈给本页面,而是告诉别的页面能不能嵌你。
2.3 内联脚本与样式:nonce、hash 和 unsafe-inline 怎么选
只要你写了 script-src,就一定会遇到内联脚本怎么处理的问题。常见的三种做法对比:
| 方案 | 写法 | 优点 | 缺点 |
|---|---|---|---|
| unsafe-inline | script-src 'self' 'unsafe-inline' | 最省事,现有代码不用改 | 脚本保护形同虚设,注入的内联 script 也能执行 |
| nonce | script-src 'self' 'nonce-随机串' | 每次响应生成一次性随机令牌,精确放行合法内联脚本 | 需要服务端参与生成并把 nonce 写入标签属性 |
| hash | script-src 'self' 'sha256-一串哈希' | 纯静态也能用,脚本内容不变就放行 | 脚本内容一变哈希就得跟着变,维护成本高 |
我的建议是:如果项目是服务端渲染或者有 BFF 层,优先上 nonce。如果页面是完全静态托管、没有服务端参与,退而求其次用 hash。无论哪种,都要尽量避免 unsafe-inline。你可能听说过“只加 unsafe-inline 表示 CSP 没生效”这种夸张说法——严格说不完全准确,style-src 里的 unsafe-inline 影响没有 script-src 里那么致命,script-src 里的 unsafe-inline 确实会让整个脚本白名单的价值大打折扣,因为攻击者注入的内联脚本一样会被执行。
3. 配置上线后的三大翻车现场:拦截、白屏与线上故障
3.1 翻车现场一:内联脚本被拦,整页逻辑静默失效
配好 CSP 后最常见的第一个事故,就是整站白屏。打开控制台一片红,满屏都是 “Refused to execute inline script because it violates the following Content Security Policy directive”。
我记得有个老项目,里面所有埋点脚本、初始化代码全写在 HTML 里,用<script>window.someConfig = {...}</script>这种形式。上线 CSP 之后页面直接失去所有交互。当时排查思路很简单——打开 DevTools Console,被拦截的内容一条条列在那里,基本不用猜。
解决方案有三条路:
- 把这些内联脚本抽成独立 JS 文件,通过 src 加载,由静态服务器提供。
- 如果脚本内容里含有服务端模板变量,实在没办法完全外置,就在生成页面时给合法的
<script>标签加一个 nonce 属性,同时 CSP 里配上'nonce-xxx'。 - 临时改用 hash 模式,先把内联脚本的 sha256 值算出来放进白名单。
第三条路适合快速止血,但不建议长期用。脚本文件每次改动都会导致 CSP 配置失效,团队里其他同事改完代码会发现页面突然又白屏了,排查半天才意识到是 hash 没更新,心态直接崩。
3.2 翻车现场二:构建工具与第三方 SDK 依赖 eval
第二个大坑是eval被禁。CSP 的 script-src 默认不允许eval以及Function构造函数这类动态代码执行。老版本 webpack 的 dev 模式下热更新会用到 eval,某些老旧的第三方图表库、埋点 SDK 内部也会用 Function 拼接代码。上线 CSP 后,表现往往是“页面正常加载,但某个功能点点击后毫无反应”,控制台报错指向一个压缩后的文件内部。
解决这个问题要分情况。如果是开发环境,可以在 webpack 配置里把devtool改成 non-eval 模式的source-map或者cheap-module-source-map。如果是生产环境某个第三方库绕不开 eval,那就得评估这个库是不是该换了。千万不要图省事直接加'unsafe-eval'——它会允许所有字符串作为代码执行,等于把 CSP 对脚本最核心的一层保护撕掉了。实在要兼容老库,也应该单独隔离:用一个不带 CSP 的 iframe 承载它,而不是让全站放开底线。
3.3 翻车现场三:第三方域名此起彼伏,connect-src 忘了配
第三个坑隐蔽得多:页面看起来一切正常,图片、脚本、样式都加载了,但只要前端往某个接口发请求就失败。很多人在配置里精心写好了 script-src 和 img-src,却漏了 connect-src。结果 fetch、XHR、WebSocket 全部按 default-src 的规则来,非本站域名一律被拦。
我在一个项目里踩得最深的一次,是接了一个在线客服 SDK。对方文档只说了“加一行 script 标签”,我配好了 script-src 域名,结果客服面板加载成功,但用户在对话框发消息时请求一直失败。打开控制台才发现,客服系统请求的接口域名在 connect-src 里没放行,WebSocket 地址也是 http 风格。
这类第三方服务的域名通常还不止一个:统计脚本会请求多个上报域名,地图 SDK 既要拉脚本又要拉瓦片还要走 XHR,支付 SDK 可能要跳转多个网关域名。我的建议是上线前用浏览器的 network 面板完整跑一遍业务核心路径,把所有发出去的请求域名整理成清单,再逐项核对 connect-src、img-src、script-src 是否覆盖完整。这个动作重复几次之后,你会对“一个 SDK 背后到底请求了多少个域名”有非常直观的认识。
4. 从报错到修复:CSP 调试与报表收集的完整链路
4.1 读懂浏览器控制台的 CSP 报错
CSP 的报错信息其实非常友好,前提是你知道怎么看。Chrome 的 DevTools Console 里,一条典型的报错长这样:
Refused to load the script 'https://example.com/analytics.js' because it violates the following Content Security Policy directive: "script-src 'self' https://static.example.com".这行信息直接告诉你了三件事:被拦截的资源完整 URL、它触犯了哪条指令、当前 CSP 里这条指令的值是什么。你可以用第二条信息去反查资源被加载的页面位置,通常在 Network 面板找到这个请求,然后借助 Initiator 列追踪它是由哪段代码发起的。
有一个容易被忽略的调试开关叫report-sample。把它追加到指令后面,比如script-src 'self' 'report-sample',浏览器在报错时就会附带被拦截脚本的前几十个字符片段,能帮你快速判断这是不是某个动态拼接的代码。生产环境不需要一直开,调试阶段开一下很管用。
4.2 Report-Only 模式:先观察再动手
如果你的站点流量很大,上来直接强制 CSP 很容易引发线上事故。浏览器给了你一个非常实用的中间模式——Content-Security-Policy-Report-Only响应头。这个头里的策略不会拦截任何资源,只是把所有违规行为上报给你指定的地址。
我的标准落地流程是这样:
- 先把完整策略放到
Content-Security-Policy-Report-Only里,配置好上游报表接收服务。 - 观察一到两周,收集真实用户环境下的违规数据。这一步特别有用,因为你自己的测试环境不可能覆盖所有用户路径,某些浏览器插件注入的脚本、用户安装的油猴脚本、企业内部网络代理改写的 HTML,都会出现在报表里。
- 根据报表逐条修复:能改代码的改代码,不能不用的第三方资源评估风险后加入白名单。
- 当报表里的违规量降到一定程度,再把策略从 Report-Only 切换成强制模式。
- 强制模式上线后依然保留上报,持续监控,因为新功能、新第三方 SDK 随时可能带来新的违规。
这个流程我反复用过多次,每次都能救我一命。直接在线上强制 CSP 就像是闭着眼睛改数据库结构,而 Report-Only 是给了你一面镜子。
4.3 report-uri 与 report-to:搭建一个简单的违规上报入口
CSP 的上报机制有两个头:report-uri是老规范,report-to是新规范。Chrome 目前主推report-to,但兼容性考量下,很多站点选择两个都写——浏览器会优先用report-to,不认的话退回report-uri。
add_header Content-Security-Policy-Report-Only " default-src 'self'; script-src 'self' https://static.example.com; object-src 'none'; report-uri /csp-report; report-to csp-endpoint; "; add_header Report-To '{"group":"csp-endpoint","max_age":10886400,"endpoints":[{"url":"/csp-report"}]}';你不需要一开始就搭复杂的上报系统。我在早期项目里拿一个最简单的 Python 脚本接收上报数据,存成 JSON 日志,每天人工扫一眼就够用了。重要的是不要收到邮件就麻木,要定期归类:哪些违规是第三方浏览器插件导致的,哪些是我们自己新上线的页面遗漏,哪些是攻击尝试。三类数据的处理方式完全不同。
5. 进阶玩法:nonce、strict-dynamic 与第三方服务共存
5.1 strict-dynamic:告别永无止境的域名清单
维护 CSP 域名清单是一件会消耗你耐心的事。今天加一个统计域名,明天换一个字体 CDN,后天又冒出一个地图瓦片域名。每加一个都要改配置,改错了还影响线上。strict-dynamic就是用来解决这个问题的。
它的核心机制是信任链传递:只要一个脚本被 nonce 或 hash 验证通过,那么由这个脚本通过 DOM API 动态加载的后续脚本也会被浏览器信任。也就是说,你不用再列出某个第三方 SDK 的完整资源域名清单,只要它的入口脚本是你 nonce 放行的,它后面动态拉取的子资源都会通过。
Content-Security-Policy: script-src 'nonce-随机串' 'strict-dynamic' https://static.example.com这里有个容易踩的细节:在支持 strict-dynamic 的浏览器里,如果策略里同时出现'unsafe-inline',这个 unsafe-inline 会被忽略。很多教程为了省事写的“strict-dynamic 配合 unsafe-inline 兼容老浏览器”其实是要小心的,不同浏览器的处理并不完全一致,建议在真实环境里做兼容性测试。strict-dynamic 的短板也很明显:老版本 Safari 不支持,如果你的用户里有大量旧 iOS WebView,就需要保留一段降级策略,或者维持原来的域名白名单方案。
5.2 与 CDN 缓存、WebSocket、外链资源的协同
使用 nonce 之后有一个很多人忽略的问题:nonce 应该是每次 HTTP 响应都不同的随机值。如果你的 HTML 页面被 CDN 或者边缘节点缓存了,那么所有用户拿到的 nonce 就变成了同一个静态值,这时 nonce 退化成了一串固定的字符串,攻击者如果知道这个值,理论上就可以利用——它的安全意义就大打折扣了。
正确的做法是让 nonce 在源站动态生成,并且确保带 nonce 的 HTML 响应不进入公共缓存,或者设置Cache-Control: no-store。如果你的页面是完全静态化部署、连服务端渲染都没有,那就只能走 hash 方案,或者接受 nonce 降级为一个“弱随机值”。这是一个取舍问题,我会优先建议为安全多留一条服务端渲染的路。
WebSocket 的域名要单独写进 connect-src,协议是 wss://,不是 https://。很多人在 connect-src 里写了https://ws.example.com,结果 WebSocket 连接秒断,控制台里连报错都看不懂。upgrade-insecure-requests指令会帮你把页面里所有 http:// 的资源请求自动改写成 https://,前提是你的站点已经全站 HTTPS。如果你的图片服务还支持 http 访问,加了这条头之后会直接走 https,后端没配 https 的话图片会全部挂掉,所以这条指令不要盲目加。
5.3 配合现代前端框架落地时的几件事
CSP 和现代前端框架共存这件事,框架本身一般不会出问题,问题出在框架生态的周边代码。常见的场景有三个:
第一,如果用 Vue 或 React,运行时的组件渲染都是用 JavaScript 操作 DOM,不会产生额外脚本。但某些模板字符串拼接后插入 innerHTML 的动态渲染方案,由于改写了 DOM 内容,还是可能被 CSP 拦截。第二,老版本 Angular 曾经依赖 eval 来解析模板表达式,升级版本前先确认目标版本对 CSP 的支持情况。第三,如果你用了服务端渲染,CSP 头必须在服务端渲染阶段生成,方便把 nonce 同时注入到 HTML 的 script 标签里——前端的静态配置是没办法动态生成 nonce 的。
还有一个经常被写错的地方是 meta 标签。你可以在 HTML 里加<meta http-equiv="Content-Security-Policy" content="...">来定义策略,它的优点是部署简单,缺点也很明显:不支持report-uri、report-to和frame-ancestors这几个最关键的指令。所以只要条件允许,尽量用响应头而不是 meta 标签。特别是 frame-ancestors 防点击劫持,用 meta 标签的话这条指令会被浏览器直接忽略,等于没防。
我自己的习惯是:CSP 响应头统一放在网关或 Nginx 层维护,框架层面的代码只负责消费 nonce,这样前后端职责清晰,也不容易出现“前端改了一版配置、运维不知道”的情况。上线之后每周花十分钟扫一眼违规报表,任何一条新的第三方资源都需要问一句:这是谁加的?为什么要加?确认之后更新白名单并留痕。坚持这个流程,CSP 就不再是一场上线时的手忙脚乱,而是一个长期能守住的安全基座。