彻底搞懂Content Security Policy中的default-src用法与避坑指南
我为什么非得折腾 CSP 的 default-src?
说真的,一开始我根本不想碰 Content Security Policy(CSP),感觉这玩意儿就是给安全工程师写的,不是给我们这些赶工期的前端用的。直到有一次上线后半小时,页面全白了——用户反馈图片不显示、第三方组件报错一堆,查了半天发现是运维在响应头里加了个 strict CSP 策略,而我们没适配。
那次之后我就认命了:CSP 这东西躲不过,尤其是 default-src,它就像是整个策略里的“默认继承类”,你不理它,它就给你找事。
最近项目重构,我们打算正式引入 CSP 来防 XSS 和资源注入攻击。核心问题来了:怎么设置 default-src 才既能保证安全,又不至于把正常功能干掉?于是我拉了几个常见方案对比了一波,亲测踩坑,今天来聊聊我的实战感受。
谁更灵活?谁更省事?
目前主流的 default-src 配置方式其实就三种:
- 直接设成 ‘self’ + 明确列出域名
- 用 nonce 或 hash 做内联脚本放行
- 干脆放宽到允许 data: 或 unsafe-inline(别笑,真有人这么干)
下面一个个来看,哪个能让我少改代码,又能过安全审计。
‘self’ 是底线,但不够用
最基础也最常见的写法:
Content-Security-Policy: default-src 'self';
意思很明确:只允许加载同源资源。JS、CSS、图片、字体、iframe 全都只能来自当前域名。听起来挺安全,对吧?
但我告诉你,这个配置一上,页面大概率直接瘫痪。为什么?因为我们用了 CDN 加载静态资源,比如 React 从 unpkg 引入,字体走的是 Google Fonts,还有个统计脚本挂在另一个子域下。
所以实际必须扩展:
Content-Security-Policy:
default-src 'self' https://cdn.jztheme.com https://fonts.googleapis.com;
注意这里有个坑:Google Fonts 实际还会请求 woff2 文件,走的是 fonts.gstatic.com,所以你还得加上这个域名到 font-src 或者单独放开 font-src:
Content-Security-Policy:
default-src 'self' https://cdn.jztheme.com;
font-src 'self' https://fonts.gstatic.com;
style-src 'self' https://fonts.googleapis.com;
看到没?default-src 虽然可以被其他指令覆盖,但你一旦用了细粒度指令(如 script-src、img-src),那对应类型就不会继承 default-src 了。这点很多人搞混,导致策略失效或过度限制。
结论:纯 ‘self’ 不现实;配合多域名单独声明还能接受,但维护成本高,每次换 CDN 都要改策略。
用 nonce 放行内联脚本 —— 我比较喜欢这个
现代框架里难免有内联脚本,比如 SSR 渲染时注入初始数据:
<script>
window.__INITIAL_STATE__ = {"user": null};
</script>
这种代码在 default-src 'self' 下直接被拦,除非你加 unsafe-inline —— 别急着这么做,那等于开了后门。
更好的办法是用 nonce。流程是这样:
- 服务端生成一个唯一随机值(nonce)
- 把它插入 CSP 头和 script 标签中
- 浏览器对比两者是否一致,一致才执行
示例代码:
// Node.js / Express 示例
const nonce = Buffer.from(Date.now().toString()).toString('base64');
res.setHeader(
'Content-Security-Policy',
default-src 'self'; script-src 'self' 'nonce-${nonce}';
);
// 在 HTML 中使用
<script nonce="abc123">
window.__INITIAL_STATE__ = {"user": null};
</script>
注意:这里的 nonce 值必须每次请求都不同,否则会被缓存绕过,失去意义。我之前图省事用了固定值,结果安全扫描直接标红,提醒“nonce predictable”。
优点很明显:精准控制哪些内联脚本能执行,不怕 XSS 注入伪造脚本。缺点是你要改造模板系统,把 nonce 传进去,前后端得配合好。
我在两个项目里用了这套方案,虽然初期折腾了两天,但现在 CI/CD 自动生成 nonce,基本无感了。如果你做的是中后台或 SSR 应用,我强烈建议走这条路。
hash 方案:适合静态构建,但灵活性差
另一种放行内联脚本的方式是计算其内容的 SHA-256 hash:
<script>
console.log("hello");
</script>
对应的 hash 是:
script-src 'sha256-qznLcsROx4GACP2dm0UCKCzCG+HiZ1guq6ZZDob/Tng='
这种方式的好处是不需要服务端动态生成值,适合纯静态部署(比如 Vite + GitHub Pages)。Webpack 插件甚至可以自动提取并注入 hash。
但我踩过一个大坑:只要脚本里多一个空格、换一行,hash 就变了。某次我加了个 log 调试,忘了删,上线后脚本全被拦截,用户登录不了。排查日志才发现是因为 hash 不匹配。
而且你没法预知所有内联脚本的内容,特别是在动态渲染场景下,hash 基本不可行。
总结:hash 更适合完全静态的项目,且脚本内容固定。对于我们这种常改逻辑的业务系统,太脆弱,我不推荐。
放宽到 data: 或 unsafe-inline?别害人害己
有些团队为了快速上线,直接这么配:
Content-Security-Policy: default-src 'self' data: 'unsafe-inline';
看起来啥都能跑了,没错。但这也意味着任何注入的 base64 图片或内联脚本都能执行。XSS 攻击最爱这种配置。
我们曾接手一个老项目,就是这么配的。后来发现某个富文本编辑器被 XSS 利用,直接弹窗盗 cookie。审计报告直接打了个 F。
所以听我的:永远不要在线上环境开启 ‘unsafe-inline’ 或无限制 data:。开发环境临时用用可以,上线前必须砍掉。
我的选型逻辑
综合下来,我的选择顺序是:
- 优先使用
default-src 'self'+ 明确列出可信域名 - 内联脚本一律用 nonce 控制,杜绝 unsafe-inline
- 静态资源尽量走 HTTPS,避免 data: 嵌入过多内容
- 必要时拆分指令(如 img-src、connect-src),不要全靠 default-src
举个我们现在的完整策略头:
Content-Security-Policy:
default-src 'self' https://api.jztheme.com;
script-src 'self' 'nonce-random123' https://cdn.jztheme.com;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
img-src 'self' data: https:;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' https://api.jztheme.com;
frame-src 'none';
object-src 'none';
base-uri 'self';
说明几点:
style-src里还留着 ‘unsafe-inline’,因为某些 UI 框架用 style 标签写样式,暂时没精力改(有空会换成 nonce)img-src允许 data: 是因为图标太多小图用 base64,但限制只能是 https 协议外链frame-src和object-src设为 ‘none’ 是为了防点击劫持和 Flash 类风险
这个策略不是完美的,但它在安全与可维护性之间找到了平衡点。改完后 QA 测试没发现异常,安全扫描也过了。
以上是我的对比总结,有不同看法欢迎评论区交流
说实话,CSP 这东西没有银弹。你得根据项目类型、部署方式、团队能力来做取舍。我见过用 Webpack 全打包然后靠 hash 跑起来的 SPA,也见过完全靠网关统一下发策略的大厂架构。
但对于大多数中小型项目,我觉得“default-src + nonce”是最务实的选择。虽然前期要改一点代码,但长期看既安全又可控。
最后提醒一句:上线前一定要用 Report-Only 模式先跑几天,收集违规报告,别一上来就 blocking,不然半夜报警你就知道了。
以上是我踩坑后的总结,希望对你有帮助。如果有更优雅的实现方式,或者你觉得 nonce 太麻烦,欢迎留言聊聊。

暂无评论