ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

前端安全必修课:CSP内容安全策略从原理到实战部署

前端安全必修课:CSP内容安全策略从原理到实战部署 1. 从XSS说起为什么前端安全越来越绕不开CSP做了几年前端见过太多表单校验一把梭的项目——前端校验只防君子不防小人真正的攻击者根本不走页面而是直接构造请求打你的接口。但要说最让人头疼的还是跨站脚本攻击XSS。攻击者把一段恶意脚本注入到你的页面里用户一访问就执行cookie、localStorage、用户操作记录全都可能被偷走。传统防护思路是过滤和转义。后端对输入做白名单校验前端对输出做HTML转义这套组合拳打了十几年可XSS依然年年霸榜OWASP Top 10。原因很简单过滤总有漏网之鱼转义总有忘记的地方。尤其是现在前端工程化之后富文本编辑器、Markdown渲染、第三方SDK注入、动态加载模块攻击面越来越多靠人肉防守已经不太现实了。Content Security PolicyCSP的思路完全不同——它不关心你输入了什么而是直接规定什么能执行。你的页面可以加载哪些域名下的脚本、哪些样式、哪些图片都用一条策略写死在响应头里。就算攻击者把恶意代码注入进来了浏览器发现这个脚本的源不在白名单里直接拒绝执行。这是一种默认拒绝的思路比试图识别所有恶意内容要可靠得多。这篇博文我会从CSP的核心原理讲起把指令体系拆开揉碎然后给出可以直接抄走的部署方案和踩坑记录。适合正在做前端安全加固的开发者也适合对浏览器安全机制感兴趣的同学——读完之后你至少能回答三个问题CSP到底怎么生效遇到页面功能被CSP拦截该怎么排查怎么在不影响业务的前提下把策略收紧2. CSP的核心机制与指令体系拆解2.1 浏览器如何理解CSP一个白名单执行器CSP本质上是一条HTTP响应头也可以写成页面里的Meta标签。浏览器加载页面时读到这个头就会按照里面的规则建立一个允许列表。之后页面里所有资源的加载、脚本的执行、样式的应用都要先问一下这个列表允许吗允许就放行不允许就拦截同时往控制台和报告端点丢一条违规记录。Content-Security-Policy: default-src self; script-src self https://cdn.example.com; style-src self unsafe-inline这条策略的意思是所有资源默认只允许来自同源脚本允许来自同源和指定CDN样式允许同源且允许内联style。浏览器解析到任何一个违反规则的请求会先拦截再报告。严格模式下资源是被硬阻断的页面可能因此白屏——这也是很多团队不敢直接上CSP的原因。后面我会讲怎么用Content-Security-Policy-Report-Only模式先监控后生效循序渐进地推进。这里面有个关键点要理解CSP不是过滤器不检查内容本身只看来源和类型。与XSS过滤器相比它不用猜测攻击者的编码方式、绕过技巧策略一旦配置正确几乎是无解的。当然前提是策略本身够严格如果为了省事把script-src配成unsafe-inline或干脆*那CSP就形同虚设了。2.2 指令速查从default-src到connect-srcCSP的指令种类很多最常用的有十几个。我按功能分成三类来讲第一类资源加载指令决定哪些资源能被加载。包括default-src兜底策略其他指令没写就按这个来script-src控制JavaScript脚本来源style-src控制样式表来源img-src控制图片来源font-src控制字体来源connect-src控制fetch、XHR、WebSocket、EventSource等网络请求media-src控制音频视频frame-src控制iframe嵌入来源第二类执行策略指令针对浏览器行为做细粒度控制script-src-attr仅控制内联事件处理器如onclickscript-src-elem控制script元素加载的脚本style-src-attr控制style属性的内联样式style-src-elem控制style元素和样式表worker-src控制Web Worker脚本第三类基础安全指令base-uri限制base标签可用的URL防止基础URL被篡改form-action限制表单提交的地址frame-ancestors控制当前页面能否被嵌入到其他页面的iframe中防点击劫持upgrade-insecure-requests把页面里的HTTP请求自动升级为HTTPSblock-all-mixed-content阻止混合内容HTTPS页面加载HTTP资源这里要特别说明default-src的优先级逻辑如果某类指令没配置浏览器会回退到default-src但如果配置了具体指令就以具体指令为准。比如只写了default-src self没写img-src那图片也只能从同源加载如果写了default-src self; img-src https://images.example.com那张图就可以从指定域名加载但脚本还是只能同源。2.3 源列表和关键字self、unsafe-inline与nonce/hashCSP的源列表有几种写法除了具体的域名和路径还有几个特殊关键字需要重点理解self当前页面的源协议域名端口注意是精确匹配HTTP和HTTPS不算同一个源none什么都不允许unsafe-inline允许内联脚本或样式unsafe-eval允许eval()、new Function()等字符串转代码的执行strict-dynamic允许已受信任脚本动态加载的脚本也获得信任需要配合nonce或hash使用nonce-xxx一次性随机数服务器在响应时生成HTML里对应的script noncexxx才被允许执行sha256-xxx内联脚本内容的哈希值计算出来写进策略里匹配的脚本才允许执行很多新手把CSP配好后发现自己的内联脚本全被拦了第一反应是加unsafe-inline——这个做法能解决眼前问题但也把CSP拦内联攻击的能力废掉了一半。更好的做法是用nonce。服务端每次响应时生成一个随机数放到响应头和页面脚本标签上两者匹配才放行。这样就算攻击者往页面里注入了一个script标签他也拿不到nonce值浏览器直接拒绝执行。Content-Security-Policy: script-src nonce-r4nd0m123 strict-dynamicscript noncer4nd0m123 // 这段内联脚本会被执行 console.log(safe inline script); /scripthash的方案适合完全静态的内联脚本因为内容不变哈希值就不会变。但如果脚本内容要经常改每次发布都得同步更新哈希维护成本偏高。我个人更推荐nonce strict-dynamic的组合既能保留内联脚本的使用便利又把执行权限制在了可信来源。3. 部署CSP的两种方式与完整配置实例3.1 响应头 vs Meta标签先搞懂两者差异CSP有两种生效方式HTTP响应头和HTML的meta标签。大多数人首选响应头因为更灵活、更安全。HTTP响应头是服务端或中间件层面配置的优点明显在HTML解析之前就生效能拦截早期注入的恶意内容支持所有CSP指令包括report-uri、frame-ancestors等Meta标签不支持的指令可以按响应分别配置粒度更细Meta标签方式则有天然限制只能写在HTML的head里且必须出现在任何外部资源加载之前不支持frame-ancestors、report-uri、sandbox等指令对已经注入到HTML前部的攻击代码Meta本身可能被篡改我的建议是能上响应头就上响应头。如果你是纯静态站点托管在Nginx、Apache或者对象存储平台上配置响应头通常就是几行配置的事。如果实在没有服务端控制权Meta标签也是可用的次优方案但一定要把Meta放在head的最前面。3.2 Nginx和Node.js里的CSP配置参考Nginx配置示例server { listen 443 ssl; server_name example.com; # 开启CSP add_header Content-Security-Policy default-src self; script-src self nonce-${request_id} strict-dynamic; 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.gstatic.com; frame-ancestors none; base-uri self; form-action self; always; # Report-Only模式先用这个观察线上情况 # add_header Content-Security-Policy-Report-Only default-src self; script-src self; report-uri /csp-report; always; location /csp-report { proxy_pass http://internal-log-service; } }加always关键字是为了确保即使返回404、500等错误状态码响应头也会带上。不然Nginx默认只在200等成功状态下加自定义头出错的页面反而裸奔。Node.js/Express配置示例const helmet require(helmet); app.use( helmet.contentSecurityPolicy({ directives: { defaultSrc: [self], scriptSrc: [self, nonce-r4nd0m123, strict-dynamic], styleSrc: [self, unsafe-inline], imgSrc: [self, data:, https://cdn.example.com], connectSrc: [self, https://api.example.com], fontSrc: [self, https://fonts.gstatic.com], frameAncestors: [none], baseUri: [self], formAction: [self], }, reportOnly: false, // 改成true就是Report-Only模式 }) );这里我用Node.js生态里的helmet中间件做示例因为它把CSP头配置封装成了比较友好的API适合快速上手。如果你不想引入额外依赖直接手动设置响应头也行app.use((req, res, next) { res.setHeader( Content-Security-Policy, default-src self; script-src self nonce-r4nd0m123 strict-dynamic; style-src self unsafe-inline; img-src self data:; connect-src self https://api.example.com ); next(); });3.3 一套可以直接套用的生产级CSP实例下面是我在真实项目中沉淀过的一套CSP策略适合一个典型的Vue/React单页应用包含第三方统计、CDN静态资源和独立API域名的情况Content-Security-Policy: default-src self; script-src self nonce-{随机值} strict-dynamic https://www.googletagmanager.com; style-src self unsafe-inline; img-src self data: blob: https://images.example.com; font-src self data: https://fonts.gstatic.com; connect-src self https://api.example.com wss://ws.example.com; media-src self blob:; frame-src self https://www.youtube.com; frame-ancestors none; base-uri self; form-action self; upgrade-insecure-requests;逐条解释一下设计意图default-src self兜底策略默认只信任同源。其他所有指令没写到的资源类型都归它管。script-src self nonce-{随机值} strict-dynamic https://www.googletagmanager.com脚本来源限制得比较严。同源、带nonce的内联脚本、受信任脚本动态加载的脚本加上特定的GTM域名。这里没加unsafe-inline所以页面里没带nonce的内联脚本都会被拦。style-src self unsafe-inline样式方面给了内联样式放行。这是实用主义的选择——很多UI库会动态往DOM里插style标签比如styled-components。如果项目里没有这个需求更建议去掉unsafe-inline但要做好排查成本的心理准备。img-src self data: blob:图片放行了data URI和blob因为很多图片处理库会把图片转成base64或通过blob显示。connect-src把独立API域名和WebSocket域名加进去。调试时如果发现某个请求被拦十有八九是这条指令没配全。frame-src只允许嵌入YouTube因为很多页面要放视频其他来源的iframe一律不给。frame-ancestors none不让自己的页面被任何第三方网站用iframe嵌入防点击劫持。upgrade-insecure-requests自动把所有HTTP资源请求升级为HTTPS适合全站HTTPS已经就位的场景。注意nonce-{随机值}这个值必须由服务端每次响应时动态生成并且写入到HTML里对应的script标签上。如果nonce写死攻击者拿到了一个固定值就能绕过。这一点切记。4. 实操过程中的常见问题与排查技巧4.1 上报违规却不影响业务Report-Only模式怎么用直接在生产环境上线严格CSP几乎一定会出问题——总有你没想到的资源加载方式被拦页面白屏、功能不可用客户投诉接踵而至。所以CSP提供了Content-Security-Policy-Report-Only模式只上报违规记录不实际拦截资源。这个模式特别适合策略部署的第一阶段。先把Report-Only头加上去让浏览器把所有的违规情况发到你的报告接收端运行一到两周你就能看清楚如果这个策略真正生效会有哪些页面挂掉、哪些功能不能用。这份报表就是优化策略的依据。Report-Only模式怎么收报告浏览器会往report-uri已完成历史使命但依然被支持或新版report-to较新规范指定的地址POST一条JSON格式的违规数据。为了快速部署很多团队会接一个云端的报告服务或者自己写一个日志接口接收。我在本地测试时最简单的方法是先用Report-Only配合浏览器开发者工具——Chrome的Console面板会直接打印CSP违规信息不用额外配置任何接收端就能看到。开发者工具里还能看到具体是哪个资源、哪条指令触发了违规联调效率很高。4.2 页面白屏、脚本不执行从Console排查到修复我在帮助团队排查CSP问题时见过的最典型场景就是加了CSP之后页面直接白屏控制台一片红色报错大多是Refused to load the script ... because it violates the following Content Security Policy directive。新手看到这条报错的第一反应是删掉某条指令但我建议先看清楚违规内容报错里说的refused to load的资源是哪个域名的如果是第三方统计脚本看它的域名是否在白名单里如果是自己的脚本看是否有nonce或者是否同源。重点看触发指令是script-src还是style-src还是其他。之前遇到过一个Case页面里用了动态生成style的图表库CSP策略里没放行unsafe-inline样式整个图表组件样式错乱调试了半天才发现是style-src的问题。把Console里的报错信息用浏览器自带的copy as cURL或直接截图发给后端同学一起看。如果你用的是Report-Only模式报错不会影响功能可以慢慢改。另外Chrome DevTools的Network面板里被CSP拦截的资源也会显示出来并且Status会显示为(blocked:mixed-content)或(blocked:other)一眼就能看出来。Safari的Console也会输出类似信息不过报错格式没Chrome那么直观。4.3 内联脚本全被拦怎么办nonce与hash的正确打开方式很多项目用了大量内联脚本——比如页面里直接写了script初始化代码、SDK回调、服务端渲染注入的数据等等。上了严格CSP之后这些代码会全部被拦页面直接失去交互能力。解决方案有两个。方案一nonce。服务端生成随机nonce值响应头里写script-src nonce-xxxHTML里的内联脚本也带上noncexxx属性。两者的值必须完全相同但这个值只有服务端和当前这次响应知道攻击者无法预知。方案二hash。把内联脚本的内容做SHA-256哈希然后把哈希值写进策略里。策略里长这样script-src self sha256-abc123...浏览器会对每个内联脚本算哈希匹配就执行。这个方案适合内容完全固定的内联脚本比如一些一次性初始化代码。如果脚本会动态变比如包含时间戳、用户IDhash就会失效每次都得重新生成。在实践中我的建议是给老项目上CSP时先通过Report-Only把现有的内联脚本全部收集一遍再决定是加nonce还是改成外部脚本文件。如果涉及的内联脚本不算太多我倾向于把它抽成外部JavaScript文件既可以让浏览器缓存也能让CSP策略更干净。4.4 第三方SDK与CDN域名适配白名单怎么加才安全现在的前端项目几乎不可能完全不用第三方资源统计脚本、地图SDK、支付组件、聊天插件、字体库……每个第三方工具都要加一条或几条白名单规则直接拼在script-src或connect-src后面。这里有个值得警惕的点白名单加得越多CSP的防护强度就越弱。尤其是一些第三方脚本本身就会动态加载其他域名下的代码如果用了strict-dynamic这些第三方脚本加载的子脚本也会被放行等于把一部分安全边界交给了第三方。所以加白名单前先问自己几个问题这个第三方脚本真的需要加载吗还是可以通过其他方式替代它动态加载的子资源来自哪些域名能否要求供应商收敛域名它的页面脚本是否可能被用户控制的内容触发比如富文本编辑器里上传的SVG是不是被第三方脚本解析执行如果团队安全要求比较高还可以考虑用subresource integritySRI给外部脚本加上哈希校验。这样即使CDN被攻击或者脚本被篡改浏览器也会因为哈希不匹配而拒绝执行。script srchttps://cdn.example.com/lib.js integritysha384-oqVuAfXRKap7fdgcCY5uykM6R9GqQ8K/uxy9RX7iQFYyCzYsQ7lY5nV9rDl7nQJ crossoriginanonymous/scriptSRI不是CSP的一部分但和CSP配合使用效果很好。CSP管的是谁能加载SRI管的是加载的内容是否可信。4.5 CSP与前端构建工具的兼容性问题现代前端项目都用Webpack/Vite打包构建产物里经常有内联的runtime脚本、动态加载的chunk、以及CSS里内联的字体/图片资源。这些都会跟CSP策略产生交互。几个高频踩坑点Vite开发模式的内联脚本Vite在dev server下会注入一段内联的client脚本用于HMR热更新。如果只给生产环境配CSP倒无所谓但如果你在本地也想用CSP调试就容易被拦。解决办法是开发环境放宽策略生产环境收紧策略不要用同一套配置。动态import的chunk加载Webpack和Vite都是运行时动态往DOM里插入script标签来加载chunk的。如果CSP的script-src里没加strict-dynamic这些动态加载的脚本会被当成不受信任的脚本拦截导致路由懒加载失效。所以strict-dynamic在现代前端项目里几乎是必加的。Web Worker注册如果用了new Worker(new URL(./worker.js, import.meta.url))这种写法需要保证worker-src self或者在script-src里覆盖。Vite打包后worker可能变成一个单独的chunk域名还是同源一般没问题但如果CDN域名和页面域名不一致就要小心了。样式表的unsafe-inline需求CSS-in-JS类库在运行时动态插入style标签如果不放行内联样式样式会直接丢失。比较务实的做法是style-src self unsafe-inline。但如果你的项目是纯静态CSS文件没有动态样式需求建议把unsafe-inline去掉。4.6 常见问题排查速查表现象常见原因处理方式页面白屏Console报Refused to load script内联脚本未加nonce或脚本域名不在白名单给内联脚本加nonce将脚本域名加入script-src样式全部丢失动态插入的style标签被拦style-src加上unsafe-inline或改用CSS-in-JS的提取方案懒加载路由打不开动态import的chunk被拦script-src加上strict-dynamic图片加载失败img-src未放行目标域名或协议检查img-src是否包含对应域名、data:、blob:fetch请求被拦connect-src未包含API域名在connect-src中加入API域名WebSocket连不上connect-src未包含ws/wss协议在connect-src中加入wss://域名表单提交失败form-action限制过严检查form-action是否包含提交的目标地址iframe内容拒绝加载frame-src未包含iframe来源在frame-src中加入对应域名第三方统计/埋点数据不上报脚本或请求被CSP拦截用Report-Only提前收集违规域名并逐步加白5. 部署策略从宽松到严格的演进路径CSP最忌讳的是一把梭。你不可能第一天就上线一套严格到极致的CSP还能保证业务不受影响。更稳妥的路径是分阶段推进阶段一Report-Only摸清家底。先上线Content-Security-Policy-Report-Only策略可以定得宽松一些重点是把所有违规情况收集起来。运行一到两周至少经历一个完整的业务周期统计违规记录的来源域名、资源类型、热度前十的拦截项。阶段二先加基础指令放开第三方。从最容易出问题的script-src和connect-src入手。把阶段一统计出来的第三方域名加进白名单先保证线上功能不出问题。同时用base-uri self和frame-ancestors none这两个指令把地基打好——这两个指令对业务影响小但防护价值很高。阶段三逐步收紧去掉宽松开关。如果项目里没有内联脚本或者已经全部改成nonce方案就可以把unsafe-inline从script-src里移除。这个阶段是风险最高的一定要配合灰度放量先在小流量上启用正式CSP头观察报错和业务指标确认没问题再逐步增加流量。阶段四完整策略上线持续监控。最终把Report-Only头移除换上完整的Content-Security-Policy。之后要做的就是持续接收违规报告及时调整策略。如果某个版本发版后新引入了一个第三方SDK等到用户反馈才去改CSP就晚了——建议把CSP的变更纳入发布检查清单每次涉及外部资源变动的发版都要同步review。这个演进路径的核心是先观察后收紧永远给自己留一条退路。CSP不是一次性的配置而是一个需要长期维护的安全策略。我见过不少团队配完CSP就再也不管了结果半年后新同学加了个第三方脚本线上用户在某个浏览器下出现白屏排查了两天才发现是CSP拦的。建立一套发版前检查CSP上线后盯报告的机制比任何一次性的严格配置都重要。6. 关于CSP我最后想说的几句实在话CSP不是银弹它不能替代输入过滤、输出转义、CSRF防护这些基础安全措施但它能把最后一道防线从依赖人的正确性变成依赖浏览器的强制机制。这么多年做前端安全的经验告诉我凡是需要人每次都不能犯错的防护方案最终都会在某一次失误中失效凡是浏览器层面强制执行的策略才能长期稳定地兜住底线。在实际工作里我建议前端团队把CSP当成一项基础设施来对待而不是某个安全专项做完就结束。它确实会给开发带来一点额外成本——内联脚本要加nonce、第三方资源要维护白名单、新SDK上线前要测试兼容性——但这些成本比起一次XSS攻击引发的数据泄露事故真的不算什么。如果你所在的项目还没有任何CSP配置我的建议是从Report-Only模式开始今天就把它加上。不用等什么完美的策略方案先让浏览器告诉你你的页面都有哪些资源加载行为摸清家底之后再一步步收紧。安全防护本来就是一场持久战方向对了慢一点也没关系。
返回列表