你手底下管着几十个线上站点,某天流量高峰期,一个不起眼的输入框突然被塞进一段恶意脚本,数据哗啦啦往外传。打开控制台一看,script标签根本来自站外域名——你压根没设防。这种事儿在圈子里的代号叫XSS,而它最直接的克星之一,就是内容安全策略(Content Security Policy,简称CSP)。
CSP这套机制干的事其实很朴素:告诉浏览器“页面上能加载什么、不能加载什么”,从源头掐断恶意脚本和非法资源请求。今天不聊纯理论,我把自己在多个实战项目里配置、调优、踩坑的全过程拆开讲,从白名单机制到nonce和hash的取舍,再到上线前的灰度验证,尽量让你看完就能直接抄作业。
1. 先弄明白:CSP到底在防什么,又是靠什么原理防住的
1.1 为什么光靠“过滤输入”不够
不少团队防XSS的思路到今天还是“后端过滤输入、前端转义输出”。这套做法在早期确实有效,但它有个天然的盲区——你永远不知道自己漏掉了哪个入口。
举个例子,我早年接手过一个后台系统,所有输入都做了HTML实体转义,测了半天也没发现问题。结果某天有用户提交了一段包含javascript:协议的内容,绕过数据库层直接被拼进了一个href属性,老浏览器渲染出来照样执行。这不是哪一环偷懒了,而是这类攻击的变种太杂:编码绕过、DOM型注入、前端模板表达式,每一种都可能有漏网之鱼。
所以“过滤”的思路本身没问题,但把宝全押在它身上风险极高。CSP的意义就在于提供第二道防线:就算恶意代码混进了页面,只要它来自不被允许的源,浏览器也会直接拒绝加载和执行。
1.2 CSP的核心机制:一份白名单政策
CSP的全称是Content Security Policy,它不是一个库也不是一个框架,而是一组由服务器下发的指令,浏览器拿到后按规则执行。
它的工作方式很像小区门禁:你(服务器)在门口贴了一张准入名单,上面写着“只有快递员、外卖员可以进”,那么再聪明的小偷假冒成业主也进不去。映射到Web场景就是:
- 默认情况下,所有资源请求都被视为“黑名单外”,一律拦截
- 每条CSP指令定义一类资源的合法来源
- 浏览器加载页面时,凡是与策略不符的请求,直接拒绝,并在控制台输出违规报告
这样做的好处是,即便你的应用存在DOM XSS漏洞,攻击者注入的<script src="https://evil.example.com/x.js">也无法生效,因为script-src指令根本没允许那个域名。
1.3 常见攻击与CSP的对应关系
先说结论:CSP主要防的是跨站脚本攻击(XSS)、点击劫持、数据注入这几大类。具体对应关系如下:
| 攻击类型 | 攻击手法简介 | CSP对应拦截机制 |
|---|---|---|
| 存储型/反射型XSS | 恶意脚本持久化或经URL带入页面 | script-src限制脚本来源、禁止内联 |
| DOM XSS | 前端代码将不可信数据拼入HTML | script-src配合strict-dynamic |
| 点击劫持 | 用透明iframe覆盖页面诱导点击 | frame-ancestors限制嵌套来源 |
| 数据窃取 | 恶意脚本向外部域名发请求 | connect-src限制请求域名 |
| 图片追踪/广告注入 | 页面加载来源不明的图片或媒体 | img-src、media-src限制资源域名 |
这里面的核心逻辑是“即使代码已经植入,也要让它跑不起来”。所以CSP的防护级别不是“发现攻击”,而是“从资源加载阶段直接封死攻击路径”。
2. 核心指令逐条拆:今天配CSP,必须像搭积木一样理解这十来个指令
2.1 必须掌握的指令清单与默认值
刚开始配CSP的人,看到一长串策略字符串容易头晕。我的经验是先抓住主干指令,理清优先级和默认行为,再延展到细节。
下面这张表是我在项目中常用的指令清单,带上了它们各自的作用和默认行为:
| 指令 | 作用范围 | 默认行为(未配置时) |
|---|---|---|
default-src | 所有未单独指定的资源类型的兜底来源 | 不设限(浏览器默认全放行) |
script-src | 脚本资源(script标签、事件属性、执行代码) | 由default-src决定 |
style-src | 样式表资源(style标签、link引入的CSS) | 由default-src决定 |
img-src | 图片资源 | 由default-src决定 |
connect-src | 通过XHR/fetch/WebSocket/EventSource发起的连接 | 由default-src决定 |
font-src | 字体文件来源 | 由default-src决定 |
frame-src | iframe可加载的地址 | 由default-src决定 |
frame-ancestors | 当前页面允许被哪些页面嵌套 | 与frame-src不是一回事 |
object-src | 插件标签(object/embed/applet) | 由default-src决定 |
base-uri | 页面<base>标签允许的URL | 若不配置,攻击者可能篡改相对路径 |
form-action | 表单允许提交的目标地址 | 不限制 |
upgrade-insecure-requests | 自动将HTTP请求转为HTTPS | 不自动启用 |
report-uri/report-to | 违规行为的报告收集地址 | 不配置则无报告 |
有人会问:为什么有了default-src,还要单独写script-src?因为我几乎不会把脚本的信任范围和图片、样式完全统一,分开配置才能精细化控制风险面。
2.2 三个最容易被误读的关键词:self、unsafe-inline、unsafe-eval
这三个词,是CSP配置里最常出现,也最容易踩坑的地方。
第一个是'self'。它不代表“所有同源资源都合法”,而是指“与页面文档同源(协议+域名+端口一致)的资源”。我见过有同事把CDN资源放在同域但不同端口,结果'self'死活不放行,排查老半天才发现是端口问题。
第二个是'unsafe-inline'。它允许内联脚本和内联事件处理器。很多老项目一上来就直接配这个,结果等于把CSP的最大优势让出去了一半——因为XSS最常利用的就是内联执行。能不用就不用,如果实在避不开,配合下面的nonce或hash机制使用。
第三个是'unsafe-eval'。它允许eval()、Function()这类动态执行。不少前端框架在开发模式下依赖它,但生产环境必须去掉。注意,'unsafe-eval'和'unsafe-inline'只在script-src里有实际意义,放在style-src里是另一码事(样式内联没那么致命,但也值得管一管)。
2.3 nonce与hash:CSP白名单的正确打开方式
很多团队觉得自己业务复杂、第三方脚本太多,没法用严格白名单。实际上,CSP提供了两种更精细的机制来解决这个问题,一个叫nonce,一个叫hash。
nonce,可以理解为一次性令牌。服务器在响应头里为每次请求生成一个随机值,同时把这个值放进页面中合法的<script>标签属性里。浏览器校验时发现属性中的值和响应头里的值对得上,才允许该脚本执行。这在单页应用里特别实用,比如你用了某个第三方分析脚本,只需要在插入标签时动态加上nonce。
hash就更好理解了,它是脚本内容的哈希值。你把某个内联脚本的SHA-256哈希写进策略,浏览器加载这个脚本时自动比对,一致就放行。缺点是脚本只要改动一个字节,哈希就变,维护成本高,只适合极少数固定不变的内联脚本。
我的习惯是:优先用nonce,因为它不绑定脚本内容,前端部署时只需动态注入属性;只有确实无法给脚本加属性(比如某些服务端渲染场景)才退而求其次用hash。
2.4 你要不要用strict-dynamic
'strict-dynamic'是个后来加入的强模式。它解决的是“nonce脚本里又动态创建了新脚本”怎么办的问题。
比如你的nonce脚本里通过document.createElement('script')插入了一个新脚本,按照传统CSP逻辑,这个新脚本的源如果不在白名单里,照样会被拦下来。但加了'strict-dynamic'之后,由受信任脚本创建的新脚本会继承信任关系,不再受源白名单限制。
它本质上是在“灵活”和“安全”之间做了取舍。启用后,原来写在script-src里的源白名单基本会被架空——所以我建议一旦开启了strict-dynamic,就不要依赖旧式的域名白名单,老老实实维护好nonce生成和校验逻辑。新的项目我基本都直接上strict-dynamic,老项目不用硬是勉强迁。
3. 动手实操:从响应头到页面加载,跑通一次完整的CSP部署
3.1 部署方式选择:响应头 vs meta标签
CSP的部署有两条路:一是通过HTTP响应头下发,二是在HTML文档里用<meta>标签声明。很多人第一次部署时都会问“是不是用meta就够”,我的回答是分场景。
响应头的优势很明确:策略优先级更高、加载前就能生效,同时支持frame-ancestors这类meta标签无法控制的指令。它需要在服务端或网关层统一配置,适合绝大多数生产环境。
meta标签的优势则是“快”,不用改服务器配置,直接在页面<head>里加一行:
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">但meta方式有两个明显的坑。第一,frame-ancestors、report-uri、sandbox这些指令在meta里不生效;第二,如果页面里原本就有违规的内联脚本,meta可能在渲染到它之前就已经被加载了,拦截效果会打折。
我给个建议:本地调试用meta没问题,但上线前务必迁到响应头方案,统一管控才是正道。
3.2 按生产环境标准写一份基线策略
不管项目多复杂,我通常从一份“严格基线”开始,再逐步放宽,而不是一开始就放开手脚。下面这份策略是我最常用来作为起点的:
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'; upgrade-insecure-requests拆开讲一下,为什么这么写:
default-src 'self'是所有资源的兜底,先锁住总体范围script-src 'self'禁止站外脚本,这是防XSS的主力style-src 'self'允许同源样式,通常前端工程化产物都打包到同域名img-src 'self' data:允许图片同源加载,同时放行base64内嵌图片object-src 'none'干脆禁掉插件类标签,减少攻击面base-uri 'self'防的是<base>标签篡改相对路径frame-ancestors 'self'限制页面只能被同源页面嵌套form-action 'self'避免表单被诱导提交到恶意站点
实际部署时,如果用到第三方统计脚本、埋点SDK或跨域API,就再往对应指令里加信任源。注意,CSP的多个来源之间是“或”的关系,空格分隔,但'self'修饰符不能随便和其他关键字混用。
3.3 针对主流服务器环境的快速配置演示
不说空话,直接上配置。我用最常遇到的三种环境做示例。
Nginx环境,在server块里加响应头:
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'self';" always;注意always这个参数,它保证即使响应出错时头也会下发。
Apache环境,在.htaccess或虚拟主机配置里写:
Header set Content-Security-Policy "default-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'self';"网关或反向代理层同样可以注入,原理一致,策略字符串才是核心。
如果项目用了Spring Boot这类后端服务,也可以用过滤器统一添加,避免每个接口手工维护。重点是策略要全局一致,不要出现某个接口漏配的情况。
3.4 与前端构建体系的配合
现代前端项目几乎都走构建工具,产物会带上一堆<script src>和<style>标签。这时候CSP配置要跟着构建体系一起设计,而不是等在服务器上瞎试。
我的做法是,在开发环境临时放宽策略,让Vite或webpack的热更新能跑起来;生产环境则切换成严策略。具体到构建配置,可以做几件事:
- 关闭生产模式下的
eval相关代码。Vite里通常把build.target调到现代浏览器版本,能天然避开不少开发期的动态执行 - 把
style打包成独立CSS文件,而不是以<style>标签内联进HTML,这样style-src就不用加'unsafe-inline' - 图片统一的CDN域名,单独加进
img-src - 如果用了webpack,资源加载时考虑给
<script>标签注入nonce属性
这些动作和CSP配置是一套组合拳,只改响应头不碰构建产物,极易在生产环境炸出一堆违规报告。
4. CSP实战上线全流程:从report-only到强制模式的灰度路径
4.1 先用灰度模式跑违规报告
CSP有个特别实用的“演练模式”:Content-Security-Policy-Report-Only。这个模式下,策略会被解析,但不会真正拦截任何资源加载,只会把违规行为汇报到指定地址。
上线前第一件事,就是先用这个头跑灰度环境。我当时在某个项目里做了两周的只报告模式,每天都能捞出意想不到的违规来源:有统计脚本的CDN域名没加进去,有某运营页面里内联了老式的onclick,还有浏览器插件注入的资源一度被误报为非法来源。
报告怎么收?最朴素的方法是配置report-uri,把违规信息发到后端某个接口,自己落日志;有条件的可以用第三方的合规上报服务,但注意国内访问稳定性。关键的排查逻辑是:报告的重复次数如果很高,说明这不是偶发,而是策略漏配,得回到配置里调整。
4.2 常见违规报错的样子和标准排查姿势
浏览器控制台和后台report的报错格式略有不同,但核心信息一致:违规的资源地址、触发的指令、页面地址、被拒原因。
举个最常见的例子:
Refused to load the script 'https://cdn.stat.example.com/analytics.js' because it violates the following Content Security Policy directive: "script-src 'self'"看到这种报错,第一件事不是急着把域名加进白名单,而是先确认这个脚本到底是不是可信的。如果可信,加到对应指令;如果不可信,说明页面被人注入了不该有的东西,这时候CSP已经帮你拦了一条攻击链路。
建议把常用的“排查-补白-复测”流程固化成下面的清单:
- 确认违规资源属于哪个角色(第一方、第三方、还是被注入)
- 确认策略指令是否写错(比如把脚本源加到了
default-src但没加script-src) - 确认CDN域名、子域与主域之间的信任关系
- 确认nonce或hash是否正确传递(尤其注意多次请求过期问题)
- 确认
upgrade-insecure-requests是否把某些混合内容强制升级导致偏差
4.3 切换强制模式的时机与操作细节
报告模式跑了一段时间,判断标准很简单:连续一周没有新增的高频违规项,或者所有报告都已经被分类处理,就可以切换到强制模式。
强制切换不是光把Report-Only去掉就行,而是要在同一条策略上闭合生效。我习惯先保持报告与强制策略完全一致(同一个字符串),这样切换时才不会有“意外惊喜”。
有一个容易忽略的点:部分浏览器对CSP响应头的处理是支持多次下发合并规则的,但Report-Only和强制头同时存在时,浏览器会同时执行两套策略。这带来的问题是,如果两套策略有冲突,行为可能难以预测。因此切换前务必清理旧的报告头,避免线上残留。
4.4 浏览器兼容性差异与实测记录
CSP从设计到实现经历了不同迭代,各浏览器对指令的支持程度有小幅差异。frame-ancestors在不多久前的版本里还只能用X-Frame-Options替代,report-to的兼容范围也比report-uri窄。
我实测下来,给几个实际结论:
| 特性 | Chrome系 | Firefox系 | Safari系 | 备注 |
|---|---|---|---|---|
script-src基础白名单 | 支持 | 支持 | 部分版本需unsafe-inline兜底 | Safari的解析偶尔更严格 |
strict-dynamic | 支持 | 支持 | 部分老版本不识别导致降级 | 可以考虑带常规白名单兜底 |
| nonce机制 | 支持 | 支持 | 新版本支持 | 老版本要配合hash或白名单 |
frame-ancestors | 支持 | 支持 | 近年版本陆续支持 | 老Safari用X-Frame-Options肩比 |
report-to | 支持 | 支持 | 支持度一般 | 稳定方案仍可沿用report-uri |
兼容性不在同一水平线时,稳妥的做法是给策略里同时保留'self'白名单,这样在不认识strict-dynamic的老浏览器上,至少不会所有脚本都被打死。这个降级细节非常关键,实践里少说能挡住一半线上事故。
5. 真实项目踩坑复盘与调优心得
5.1 内联脚本误伤:为什么你的框架一崩再崩
最经典的线上事故长这样:CSP一上线,页面白屏,控制台全是script-src拒绝加载的报错。翻报告一看,报错的全是为数不少的内联脚本——其中一半来自框架本身的初始化逻辑,另一半来自业务代码里的onclick与eval。
这类问题没办法通过“放宽到unsafe-inline”来解决,否则CSP等于白做。正确的做法是分两步走:
- 给框架注入的脚本加上nonce,让可信内联代码获得放行
- 把业务代码里直接写死的
onclick改成事件监听器绑定,从根上消除内联依赖
当时我们用了一个笨但有效的土办法:写个小脚本扫描构建产物,找出所有内联块和onclick属性,逐个确认归属,再决定是改造还是加nonce。虽然费时间,但比上线后盲猜强十倍。
5.2 忘记改造第三方SDK引发的连环问题
第三方SDK经常是CSP上线过程中的“定时炸弹”。尤其是那些在页面里动态创建脚本的SDK,在strict-dynamic未开启时会集体失效。
我在某电商类项目里集成过几个数据分析SDK,打包后发现线上数据丢失了一大半。排查到最后,是其中一个SDK用document.write注入脚本,而CSP把这类动态写入视作非法加载。
怎么处理?我的建议是:
- 优先看SDK官网是否提供CSP兼容模式,很多大厂都有
- 实在不兼容,用一个轻量的代理层代替实时脚本加载,服务端抓取数据后转发
- 动态创建脚本的,评估开启
strict-dynamic并配合nonce继承机制
这部分的止损思路很明确:要么改造SDK的使用方式,要么在CSP策略里为其单独放行,但放行时要克制,别为了一个功能放开一整块。
5.3 上报接口的自我保护:别让报告接口成为新的风险点
report-uri/report-to虽然只是收报告,但它本质上也暴露了一个接口。万一被刷、被塞垃圾数据,照样能拖垮后端。
我给报告接口定了几条规矩:限流、鉴权(至少校验来源)、落库时限制报告大小和字段数量。内置的违规报告虽然本身不可信,但你不知道运营时会不会有人恶意制造海量报告搞你。
5.4 CSP性能影响实测报告
很现实的问题:CSP会不会拖慢页面?
我自己的测试数据是,在同等网络和资源规模下,加CSP对静态资源加载的耗时影响微乎其微。真正会影响性能的,是错误配置造成的连环重定向(比如upgrade-insecure-requests把不支持HTTPS的资源搞坏了)以及错误报告量过大带来的日志压力。
所以性能优化的重点不在策略本身,而在“别让浏览器反复加载失败的资源”。CSP拦截发生的越早,后续的网络资源抢占就越少,这反而是个好事。
5.5 策略爆掉的急救方式
怕的就是线上策略配错导致整站不可用。所以任何时候都要在服务器配置里留一套“后手”,比如快速回滚到空策略的办法,或者用Nginx变量动态生成策略,在紧急时一键放行。
我习惯把CSP策略抽成独立变量,这样调试时不用改业务配置:
set $csp_policy "default-src 'self'; script-src 'self';"; add_header Content-Security-Policy $csp_policy always;这样一旦出事,只需临时修改变量值reload即可,不用翻整份配置找那块写死的头。
6. 总结之外的那些实战心得
写到这里,CSP从原理到实操的基本盘算是讲全了。最后分享一点个人体会:CSP不是“配了就能睡大觉”的安全水印,也不是“一劳永逸的银弹”。它在开发阶段会给你制造不少麻烦,但在上线后,它会安静地帮你挡掉大量你根本没发现的攻击尝试。
我给新项目的标准动作是三步走:第一,从严格基线策略开始,跑报告模式,边跑边收集违规边迭代;第二,结合构建和部署流程,让nonce和hash的生成自动化,严禁手工维护策略字符串;第三,灰度观察一周到两周,确认无新增高频违规后再切强制模式。
如果非要给后来者留一句最值得记住的提醒,那就是:永远不要在CSP里用明文写死业务数据,也不要图省事直接放开unsafe-inline。宁可多花两天改造内联脚本,也别把一个本来就防误用的开关轻易打开。
这台“门禁系统”装得好,你的站点就多了一层看不见的韧性。我也还在不断调整自己的策略,后续有新坑欢迎一起交流。