
做Svelte项目做了快三年最近几次给应用做上线前的安全加固几乎每次都会被CSPContent Security Policy卡一轮。先是一打开页面控制台满屏的“Refused to execute inline script”然后又是接口请求被connect-src拦掉最后折腾完又发现第三方统计脚本把strict-dynamic干翻了。这套流程走下来我对“Svelte CSP”的组合算是彻底摸透了。这篇东西不是把MDN文档翻译一遍而是把我在真实项目里怎么设计、怎么配置、怎么排查CSP策略的完整过程记录下来。不管你是用SvelteKit做整套应用还是用Vite Svelte写个中后台前端只要想给项目加上CSP或者正在被CSP报错折磨这篇内容应该能帮你少走不少弯路。我会从CSP的基础取舍讲起给到SvelteKit和普通Vite项目的两套落地配置最后再把我踩过的坑整理成一份可以直接对着排查的故障表。1. 为什么Svelte应用里做CSP特别容易踩坑1.1 Svelte的编译模型和CSP天然有摩擦很多前端开发者第一次接触CSP时觉得这不就是个响应头吗往服务器上一挂就完事。但放到Svelte项目里事情就没那么简单。Svelte和React、Vue最大的区别在于它是一个编译器。你在.svelte文件里写的那些模板语法、组件逻辑最后都会在构建阶段被编译成纯JavaScript操作DOM的代码而不是带着一大坨运行时框架跑。这听起来和CSP没什么冲突但问题出在Svelte生成的代码经常会以内联脚本的形式出现。尤其是SvelteKit这类框架为了做路由预加载、页面数据序列化、hydration它会在HTML里插入一段内联的启动脚本。如果CSP的script-src设置成self这种常规配置这些内联脚本全部会被浏览器拒绝执行。另一个容易忽略的点是Svelte的组件里可以写style块这些样式在开发模式和生产模式的处理方式不同。生产构建下样式会被提取成独立的CSS文件这样倒还好。但如果你在组件里通过style:指令去动态绑定样式或者用了某些需要运行时注入样式的库CSP的style-src又把你的路堵死了。Svelte应用对CSP的敏感度比普通React应用要高得多因为SvelteKit默认就依赖内联脚本来工作这是框架的架构决定的不是你写代码不小心造成的。很多人在这一步就开始慌了然后直接放弃CSP或者加unsafe-inline把所有东西全放行这恰恰是本末倒置。1.2 你看到的报错可能来自三个不同层级排查CSP问题之前先搞清楚报错是从哪一层冒出来的这能省掉大量时间。我习惯把Svelte项目的CSP报错分成三层。第一层是静态资源层。HTML本身可能带了内联脚本比如你在src/app.html里手写的初始化脚本、SvelteKit自动注入的preload脚本这一层管的是“页面加载时能执行什么”。第二层是运行时动态加载层。项目用了动态import()拆包或者某个第三方库在运行时往head里插了script标签又或者基于new Function()做代码生成。这一层归script-src下面的strict-dynamic和nonce机制管。第三层是网络请求层。页面加载后前端向后端发API请求、上传日志、加载图片字体这些分别受connect-src、img-src、font-src控制。很多Svelte项目会配置多个API地址或走网关代理这里最容易出现“页面能打开但所有接口都挂掉”的诡异现象。我在实际排查时会先把浏览器的Console窗口单独拉出来按Error级别过滤然后一段段看报错信息里的violated directive字段。这个字段会直接告诉你违反了哪条指令不要自己瞎猜。搞清楚是哪一层的问题再决定是改代码、改配置还是改服务器响应头。2. 动手前先搞懂CSP指令到底要怎么写2.1 常用指令的取舍与最小授权原则真正开始配置CSP之前先花五分钟想清楚你愿意承担多少维护成本。CSP不是越严格越好严格到连自己都跑不动最后同事怨声载道策略迟早被删掉。我常用的基础指令集是这样的default-src self script-src self strict-dynamic nonce-xxxx style-src self unsafe-inline img-src self data: https: connect-src self https://api.example.com font-src self data: object-src none base-uri self frame-ancestors self逐条说下思路。default-src self是所有没写明的指令的兜底值这里卡死防止遗漏。script-src是最核心的一条。我没有在基础配置里直接放unsafe-inline因为SvelteKit的csp模块会自动给内联脚本打hash或者nonce后面会展开讲。加上strict-dynamic之后所有被nonce信任的脚本创建的子脚本也会被信任这样动态加载import()、模块化脚本才不会被拦。style-src里我留了unsafe-inline这其实是一个有意识的妥协。Svelte组件中样式写在style里编译后通常会被提取为外部CSS但一些动态绑定的style属性以及第三方组件的内联样式仍然存在。如果此时把内联样式彻底禁掉最容易出现的后果是日期选择器、富文本编辑器这类组件变得完全没有样式这种问题的排查成本远大于保留unsafe-inline带来的风险。connect-src这里一定要把项目所有可能访问的接口域名列全。很多SvelteKit项目同时有自己后端的API地址、第三方上传服务地址、错误监控上报地址漏一个就有一类请求失败。object-src none和base-uri self属于成本极低但收益很高的两条指令建议直接加上防止某些老旧的插件加载攻击。frame-ancestors self则能避免你的应用被别的网站通过iframe嵌入这个在做登录类系统时特别有用。2.2 选nonce还是hash我建议按这两个条件判断CSP里最难理解也最常被问的就是unsafe-inline的替代方案nonce和hash。两者都能让浏览器放行指定的内联脚本但使用场景完全不同。Nonce的意思是给每个内联脚本一个一次性随机数服务端在返回HTML时生成一个随机值同时写到CSP响应头和script nonce...标签里。浏览器比对两者一致才会执行脚本。它的特点是动态的每次页面请求都要重新生成。Hash则是把内联脚本的内容做SHA-256摘要写到CSP里只要脚本内容不变策略就始终有效。它的特点是静态的适合内容固定的内联脚本。我给项目做选择的逻辑很简单如果用了SvelteKit这类自带服务端渲染的框架直接走nonce路线。SvelteKit的csp配置开启后会自动生成并注入nonce不需要你自己操心同步问题。如果是纯静态部署的Vite Svelte项目那么大多数情况下没有动态生成响应头的地方这时用hash更现实。你只要构建一次把script内容对应的hash值记下来写进CSP头就行。当然前提是这个脚本内容在多次构建之间保持稳定。如果每次构建hash都会变反而更麻烦。一个需要特别注意的坑是一旦在script-src里使用了nonce或hashunsafe-inline会自动被浏览器忽略。这是CSP规范里的设计——当你提供了更安全的替代方案浏览器会无视unsafe-inline。所以不要抱着“我两个都写上总没问题吧”的心态配置这只会让安全审计人员觉得你不懂CSP。2.3 strict-dynamic带来的现代CSP写法早几年写CSP时大家普遍会把第三方脚本域名直接放进白名单比如加一条https://cdn.bootcdn.net。这种做法在今天是很难维持的因为你依赖CDN域名而这个域名下任何一个文件被劫持整个页面就沦陷了。strict-dynamic解决的就是这个信任链问题。它的规则很简单只要是受信任脚本通过nonce或hash校验创建的后续动态脚本也自动获得信任。也就是说HTML里那个带nonce的主脚本可以new一个script标签去加载SDK这个SDK加载出来的子脚本不需要再出现在CSP白名单里。举个实际场景。我在项目里接入了前端错误监控SDK它需要在运行时加载一个远程脚本。配置了strict-dynamic之后主页面脚本通过document.createElement(script)去加载SDK只要主脚本的nonce是合法的这个动态脚本就会被允许执行。我不用再去关心SDK最终从哪个域名加载也不用担心以后换CDN导致策略失效。P.S. 如果你想进一步了解strict-dynamic对老浏览器的影响一个比较省事的兜底方案是在加了strict-dynamic的同时保留self这样老浏览器会忽略strict-dynamic并按照白名单校验新浏览器则优先采用严格模式。3. SvelteKit项目的CSP落地配置推荐方案3.1 在svelte.config.js里开启内建CSP支持SvelteKit官方其实内置了CSP的支持这在很多框架里是不多见的用起来也简单。只需在svelte.config.js里增加一个csp配置项import adapter from sveltejs/adapter-auto; const config { kit: { adapter: adapter(), csp: { mode: auto, directives: { base-uri: [self], object-src: [none], script-src: [self], style-src: [self, unsafe-inline], img-src: [self, data:, https:], connect-src: [self, wss:], font-src: [self, data:], frame-ancestors: [self] } } } }; export default config;重点解释mode: auto。SvelteKit文档里说得很清楚在auto模式下框架会自动分析HTML中需要放行的内联脚本然后为它们生成nonce或hash并注入到CSP策略中。对大多数项目来说这近乎是零成本的配置方式。它会自动处理SvelteKit自身用于hydration、页面数据序列化产生的那些内联脚本不用我们手工维护hash列表。服务端渲染直接以完整的CSP响应头输出给浏览器纯静态部署的时候SvelteKit会把策略写成meta http-equivContent-Security-Policy内嵌在HTML中。两种模式我都在生产环境跑过没有发现功能上的差异只是一般能上HTTP响应头就优选用响应头因为meta标签方式的策略在某些浏览器里有一些限制如report-uri无法生效。3.2 自定义connect-src、report-uri与更多指令内置配置保证了最基础的可用性但实际项目中我们需要扩写很多指令。最典型的就是connect-srcSvelteKit应用八成会跟WebSocket或不同域名的API打交道。我之前有个项目就是这样前端部署在A域名接口在B域名日志上报在C域名如果只写connect-src: selfB和C会被全部拦截。所以我的做法是在默认指令的基础上覆盖自定义的connect-srccsp: { mode: auto, directives: { script-src: [self], connect-src: [ self, https://api.example.com, https://log.example.com, wss://api.example.com ], // 其他指令保持默认 } }除此之外强烈建议加上report-uri或report-to。这两个指令不同之处在于report-uri是老标准report-to是新标准实际项目中可以只用其中之一也可以都写上。它的作用是让浏览器在违反策略时把违规详情上报到你指定的服务端接口而不是只默默在控制台输出一条错误。这样可以集中收集策略误伤情况。SvelteKit里配置起来也简单csp: { mode: auto, directives: { // 上面那些指令 }, reportOnly: { // 仅报告的配置 } }这里我特别说一下reportOnly字段。它相当于一个“影子CSP”——浏览器会执行所有检查并上报违规但不会真的拦截任何内容。我每次调整CSP脚本时都会先在reportOnly模式下跑个两三天收集线上真实用户的违规数据确认没有误伤之后再正式切换。如果你直接上线一个严格CSP很容易毫无过渡地打断线上用户的操作。3.3 验证配置用中间人页面和浏览器控制台配置好之后怎么确认它真的生效并且没有误杀东西这里我有一套固定的验证流程。先用curl -I看一下响应头是否正常返回curl -I https://your-app.example.com正常情况下能看到content-security-policy响应头。如果走的是meta标签方式就直接查看HTML源码确认meta http-equivContent-Security-Policy出现在head最前面。然后打开浏览器访问首页把DevTools切到Console按级别过滤Error。正常情况下页面所有功能都应该正常执行控制台不应该出现任何CSP相关的红色报错。接着系统地过一遍核心用户流程登录、跳转、数据加载、文件上传、图表渲染、支付回调。每个环节都用浏览器审计面板看一下有没有资源被拦或者请求失败。这个流程看着繁琐但真的非常值得做。我之前有一次只改了connect-src以为影响面很小就没过流程结果图表组件里加载的云端字体被font-src卡住了图表区域全部回退成系统字体视觉上乱了套还是用户在群里截图反馈才被发现。4. 普通Vite Svelte项目怎么补上CSP4.1 把CSP交给服务端Nginx和Express的设置方式不是所有人都在用SvelteKit很多项目还是常规的Vite Svelte打包出来是一堆静态文件然后扔到Nginx或者云存储上。这种场景下把CSP写进构建配置的收益不大因为响应头最终由Nginx来出。最简单的落地方式是直接在服务器配置里加响应头。例如Nginxserver { listen 443 ssl; server_name your-app.example.com; root /var/www/svelte-app; index index.html; add_header Content-Security-Policy default-src self; script-src self sha256-xxxxx; style-src self unsafe-inline; img-src self data:; connect-src self https://api.example.com; object-src none; base-uri self; frame-ancestors self;; }使用add_header的注意事项是如果你在一个server块里有多个add_header指令Nginx默认只会输出当前作用域里第一条匹配的add_header所以最好把CSP和其他安全头写在一起。Express服务端则更直接app.use((req, res, next) { res.setHeader( Content-Security-Policy, default-src self; script-src self sha256-xxxxx; style-src self unsafe-inline; img-src self data:; connect-src self https://api.example.com; object-src none; ); next(); });这里的sha256-xxxxx指的就是你构建产物中那个内联脚本的hash值。具体怎么拿到这个hash值我在下一节说。4.2 构建期注入nonce一个轻量插件思路如果静态站点的内联脚本比较多用hash一个个维护实在痛苦另一个思路是利用服务端模板在返回HTML时注入nonce并在响应头中动态返回对应的CSP策略。一个比较轻量的做法是用一个极小的Express中间件读取HTML文件替脚本标签加上随机nonce同时重新生成响应头import express from express; import { readFileSync } from fs; import crypto from crypto; const app express(); const html readFileSync(dist/index.html, utf8); app.use((req, res, next) { const nonce crypto.randomBytes(16).toString(base64); const injectedHtml html.replace(/script/g, script nonce${nonce}); res.setHeader( Content-Security-Policy, script-src nonce-${nonce} strict-dynamic; object-src none; base-uri self; ); res.send(injectedHtml); }); app.listen(8080);这段代码核心思路是不让CSP和HTML各自独立生成nonce而是先由中间件生成一个nonce然后同时注入到HTML标签和响应头里。放到真实项目中最好还要处理非脚本标签的替换、缓存策略、多页面入口等但作为起步方案已经足够跑通。用这个方案时有一点要注意如果你的HTML里已经通过link relmodulepreload加载了Svelte打包后的模块那么这些模块脚本同样需要携带nonce否则会被script-src拦截。上面例子中我把所有script标签都处理了但modulepreload不算是脚本执行它受script-src约束的机制和普通脚本不同踩过这个坑具体看下一节的排查表。4.3 Svelte模板里内联样式的合法化处理静态项目的另一个高频问题出在样式上。Svelte组件的样式会被编译进JS或者提取成CSS文件这部分跟CSP握手良好因为最终都是外部文件。但如果你在模板里用style:指令动态设置样式或者第三方组件通过JS改DOM样式浏览器就会触发style-src的检查。最省事但安全度最低的方法是把style-src加上unsafe-inline这也是很多项目实际采用的做法。我后来找到一个相对平衡的方案在构建时强制提取Svelte的所有静态样式为独立CSS文件同时禁止style块中的动态样式注入。Vite Svelte插件的配置可以这样写import { svelte } from sveltejs/vite-plugin-svelte; export default { plugins: [ svelte({ compilerOptions: { css: external } }) ] };把css设置成external后Svelte会把组件样式抽取成独立CSS文件而不是通过JS插入到文档中。这样一来CSP的style-src就可以只写self而不需要unsafe-inline整个页面完全走外部资源路径。代价也有。动态绑定在元素上的style:指令仍然会以inline style的形式出现这是浏览器API本身的行为绕不开。如果你项目里大量依赖动态样式绑定我建议还是老老实实保留style-src unsafe-inline或者通过CSS自定义属性把这些值抽到类名上从源头上减少内联样式的产生。5. 实战排障实录Svelte CSP高频报错5.1 “Refused to execute inline script”怎么定位这是最常见的CSP错误同时也是信息量最大的一条。先完整decode一下这条报错Refused to execute inline script because it violates the following Content Security Policy directive: script-src self. Either the unsafe-inline keyword, a hash (sha256-...), or a nonce (nonce-...) is required for inline execution.定位方法分三步。第一步在DevTools的Elements面板里查看到底是哪一段内联脚本被拦。被拦的脚本通常会被标记成灰色。第二步判断这段脚本是框架生成的还是自己手写的。如果是SvelteKit框架生成的优先检查csp配置是否开启了mode: auto如果是自己手写的初始化逻辑把它移到外部JS文件里就会干净很多。第三步对于确实无法外部化的脚本用hash或nonce放行。我个人的意见是能外部化的代码尽量外部化hash和nonce是给确实绕不过的场景准备的不是给懒人准备的。另外有个细节浏览器控制台显示的错误位置是HTML的行列号但构建后的HTML可能是经过压缩的行号不够直观。这时用curl -o -把HTML拉下来格式化再看会轻松很多。5.2 connect-src误伤API请求的排查路径connect-src误伤API的情况表现和报错长这样Refused to connect to https://api.example.com/v1/xxx because it violates the following Content Security Policy directive: connect-src self排查逻辑不复杂。先在控制台确认发起请求的域名然后检查connect-src里有没有这个域名。这里容易漏的是浏览器对https和wss是不同协议域名要分别写明如果API是https://api.example.com那么http://api.example.com不会自动放行端口不同也会导致匹配失败https://api.example.com:8443和https://api.example.com是两回事。更隐蔽的是跨域请求里的预检请求OPTIONS。浏览器在跨域POST之前会先发一个OPTIONS请求做CORS预检这个预检请求同样会被connect-src拦截。你会在Network里看到OPTIONS请求直接failed而真正的POST请求根本没发出去。这种情况下先把connect-src的范围扩大把预检域名加进去再看后端CORS是否允许这个来源。5.3 style-src漏配导致组件样式全部丢失Svelte项目在开启CSP后样式丢失九成是style-src写死了self而动态插入的样式被拦掉。现象非常迷惑页面结构正常、内容正常、交互也正常但UI就像素级地回到了1999年。处理这个问题时我先确认一点如果项目使用了Svelte的style标签并且css配置为injected那么样式是通过JS以style节点注入的这种情况必须给style-src放行unsafe-inline。如果已经用css: external强制提取成独立CSS文件那么style-src保持self即可。另外第三方组件的样式不受你构建配置的控制。很多UI库会在运行时动态注入样式遇到这种组件要么换一个纯CSS方案要么在style-src里开unsafe-inline。这两条路都不完美但总比线上样式全崩要好。5.4 unsafe-eval哪些第三方库会触发它CSP报错里最难以驯服的其实是unsafe-eval相关的问题。报错格式如下Refused to evaluate a string as JavaScript because...这意味着有代码在执行eval()、new Function(), 或者等价能力的操作。Svelte本身在dev模式下可能会用到eval来支持HMR热更新但生产构建一般不会。问题通常出在第三方库身上。我遇到过的典型情况某个老版本的日期处理库在运行时用new Function做模板字符串转义、某个图表库用了动态代码生成、某个富文本编辑器为了兼容低版本浏览器内置了eval。这些库你是很难改源码的所以处理方式无外乎两种换掉这个库或者在script-src里加入unsafe-eval。我自己的优先级是先换库换不了再加unsafe-eval而且要开reportOnly盯一周确认没有别的意外。有几个细节要注意new Function和eval虽然性质相同但CSP会以同样的方式拦截。Svelte只在开发模式用到eval如果你在开发模式开了CSP而生产环境没有那不是你环境配错了是预期行为。还有很多构建工具的dev source map功能也会依赖eval开CSP调试时容易误伤可以把dev server的CSP单独放宽松一点线上再用严格策略。5.5 report-only模式先建监控再上强度如果你正在给一个已经上线的老项目加CSP我强烈建议先别直接开严格策略而是走一轮report-only。SvelteKit的配置里csp.reportOnly字段就是干这个的。它同样会执行检查、同样会在控制台打日志但不会真的阻止任何资源加载或脚本执行。这样可以一边收集线上真实违规数据一边判断哪些情况属于“必须放行的合法使用”、哪些属于“需要改代码的隐患”。收集几天数据后再对照report里的内容逐一分类。我的习惯是建一个二维表格来管理违规项类型示例处理策略框架自身内联脚本SvelteKit的hydration脚本交给框架自动nonce/hash解决常见第三方统计脚本站点分析、埋点SDK优先用strict-dynamic覆盖必要时加白名单域名历史遗留旧代码jQuery时代逻辑、eval字符串拼接推动重构彻底移除eval用户端临时环境用户装了某些浏览器插件注入脚本忽略插件注入不受页面CSP控制这个表的每一行都要有负责人和截止时间不然漏掉一条正式切严格模式的时候线上就会炸一个功能。6. 三个值得长期坚持的CSP实践习惯6.1 把CSP写进CI的门禁脚本CSP策略容易在不知不觉中腐化。新同事加入项目随手在index.html里加了个内联脚本某个第三方库升级后改用动态加载脚本这些都不会报红因为CSP没有匹配到被拦的资源就会保持静默。但一旦将来你收紧CSP这些历史债务就会集中爆发。我的做法是在CI里加一个简单的检查步骤拉取构建产物HTML和JS扫描其中内联脚本的内容对比CSP里声明的hash或nonce机制是否覆盖。更加简单粗暴的做法是直接用无头浏览器加载页面然后监听console里的CSP违规报错有报错就fail构建。用Playwright写这个脚本工作量并不大import { chromium } from playwright; const browser await chromium.launch(); const page await browser.newPage(); const cspErrors []; page.on(console, (msg) { if (msg.type() error msg.text().includes(Content Security Policy)) { cspErrors.push(msg.text()); } }); await page.goto(http://staging.example.com); await page.waitForLoadState(networkidle); if (cspErrors.length 0) { console.error(cspErrors.join(\n)); process.exit(1); } await browser.close();是的这个方法很简单但真的能拦住大部分回归。6.2 定期回归检查第三方依赖CSP策略写完之后最怕的是依赖升级。第三方SDK升级后可能切了CDN域名、加了新的接口域名、改成了动态脚本加载这些都会让原本可用的CSP策略瞬间作废。我的习惯是每个月做一次全量依赖审查。把package.json里的第三方库过一遍尤其是那些在浏览器端运行的SDK逐个确认它们需要的CSP权限是否在现有策略内。如果策略已经很严了新引入的库大概率会撞墙那就提前处理而不是等线上反馈。这个工作同样可以写脚本辅助把CSP指令解析后对照页面上实际加载的所有资源URL自动把不在白名单里的资源列出来。手动做也不是不行只是项目大了之后肉眼核对容易疲劳。6.3 用CSP Evaluator做灰度前的快速体检上线前的最后一关我会把最终的CSP策略扔到CSP Evaluator这个工具里过一遍。它会用一组启发式规则评估策略的安全性特别是针对strict-dynamic的配置是否合理、是否存在无意义的unsafe-inline残留、unsafe-eval是否确实必要等。这个工具能配置的默认策略一般比我们日常写的要严格但它最大的意义是督促你不要偷懒。比如我常犯的一个毛病是为了让某个老库能运行在script-src里加了unsafe-eval后来这个库换掉了但unsafe-eval却一直没删。CSP Evaluator就会直接标记这个配置有风险。每次灰度前花五分钟过一遍成本很低收益却很确定。真正做到项目后期我觉得CSP策略更像是一份活文档它会随着项目演进不断变化。你不需要追求“消灭所有违规”而是要保证每一处放行都有明确的原因、有对应的代码位置、有负责人知道为什么这么放。这套思路放在Svelte项目里尤其重要——框架本身对CSP的支持已经做得很好剩下需要操心的是项目里那些被框架隐藏起来的动态行为是否跟你写的策略保持一致。