strict-dynamic实战指南:绕过CSP限制的正确姿势

开发者江洁 安全 阅读 2,395
赞 11 收藏
二维码
手机扫码查看
反馈

为啥我要折腾 strict-dynamic?

最近项目要过安全审计,CSP(Content Security Policy)成了绕不开的坎。以前我们用 unsafe-inline 图省事,结果被安全团队喷得狗血淋头。行吧,改。但一查资料,发现 CSP 里有个叫 strict-dynamic 的东西,说是能解决动态脚本加载的问题,听起来很香。可实际用起来,方案五花八门,有的靠 nonce,有的靠 hash,还有的直接上 strict-dynamic 配合白名单。我折腾了快两周,踩了好几个坑,今天就来唠唠这几个方案到底谁更靠谱。

strict-dynamic实战指南:绕过CSP限制的正确姿势

谁更灵活?谁更省事?

先说结论:**我比较喜欢用 nonce + strict-dynamic 的组合**,虽然配置麻烦点,但长期来看最省心。下面我拿三个常见方案对比一下,都是我亲测过的。

方案一:纯 hash(不推荐)

这是最“原教旨主义”的做法——给每个 inline script 算 SHA256 哈希值,加到 CSP 里。比如:

<script>
  console.log('hello');
</script>

对应的 CSP 头得写成:

Content-Security-Policy: script-src 'sha256-9dK7qG1jJx8QZ3yX4wV5uT6rS7tY8iO9pL0mN1bM2cD3eF4='

问题来了:你改一行代码,哈希就得重算。本地开发还好,CI/CD 流程里得专门加个脚本自动注入,贼麻烦。而且第三方库(比如 Google Analytics)根本没法用,因为人家代码是动态生成的。我试过一次,改完后页面一半功能挂了,最后只能放弃。所以除非你项目极其简单、完全静态,否则别碰这个。

方案二:nonce + 白名单(过渡方案)

这个方案我一开始用过,思路是:给每个 inline script 加一个随机 nonce,同时保留几个可信域名:

<script nonce="a1b2c3d4">
  // 你的代码
</script>
Content-Security-Policy: script-src 'nonce-a1b2c3d4' https://cdn.jsdelivr.net https://jztheme.com

看起来挺美,但问题在于:**一旦你用了动态加载(比如 import()、eval-like 的 new Function),或者第三方 SDK 自己又加载了子脚本,白名单就崩了**。我就栽在这儿——某个埋点 SDK 内部用 document.write 插入脚本,结果被 CSP 拦了,用户行为数据全丢。后来发现,这种方案本质上还是“打补丁”,哪漏补哪,维护成本高得离谱。

方案三:nonce + strict-dynamic(我的首选)

这个方案的核心思想是:**只信任带 nonce 的根脚本,它加载的所有子脚本自动继承信任**。CSP 头这么写:

Content-Security-Policy: script-src 'nonce-a1b2c3d4' 'strict-dynamic'

注意:这里甚至可以去掉所有域名白名单!因为 strict-dynamic 会忽略 https:'self' 这些源列表,只认 nonce 或 hash 启动的信任链。

举个实际例子。我在入口 HTML 里放一个带 nonce 的启动脚本:

<script nonce="a1b2c3d4">
  // 动态加载业务模块
  import('./main.js').then(m => m.init());
  
  // 甚至可以这样加载第三方 SDK
  const script = document.createElement('script');
  script.src = 'https://jztheme.com/sdk.js';
  document.head.appendChild(script);
</script>

神奇的是,main.jssdk.js 都能正常执行,哪怕它们来自不同域名!因为浏览器认为:既然父脚本是可信的(有 nonce),那它加载的子资源也该信。这比手动维护白名单灵活太多了。

踩坑提醒:这三点一定注意

别看 strict-dynamic 很香,但有几个坑我踩过好几次,必须提醒你:

  • nonce 必须每次请求都随机生成。我见过有人把 nonce 写死成 abc123,这等于没加 CSP,攻击者直接复用就行。正确做法是在服务端每次渲染 HTML 时生成唯一 nonce,比如 Node.js 里用 crypto.randomBytes(16).toString('base64')
  • 旧版浏览器不支持 strict-dynamic。主要是 Safari 12 以下和 IE 全家桶。如果你的用户还有这些古董,得做降级:同时保留 'unsafe-inline' 和域名白名单,但用 strict-dynamic 覆盖现代浏览器。CSP 头可以这么写:
    Content-Security-Policy: script-src 'nonce-a1b2c3d4' 'strict-dynamic' https: 'unsafe-inline'

    浏览器会优先用 strict-dynamic,不支持的就 fallback 到白名单 + unsafe-inline(虽然不安全,但没办法)。

  • 内联事件处理器(如 onclick=”…”)永远不被允许。即使你用了 nonce,<button onclick="doSomething()"> 这种写法照样被拦。必须改成 JS 绑定事件。这点我一开始没注意,页面一堆按钮失效,查了半天才反应过来。

我的选型逻辑

现在我基本固定用这套流程:

  1. 服务端每次渲染 HTML 时生成唯一 nonce
  2. CSP 头只写 script-src 'nonce-xxx' 'strict-dynamic'(兼容旧浏览器再加白名单)
  3. 所有 inline script 必须带 nonce,禁止内联事件
  4. 第三方 SDK 尽量通过根脚本动态加载,而不是直接写在 HTML 里

这套方案上线后,安全审计一次过,而且后续加新功能再也不用改 CSP 头了。虽然初期改造有点疼(尤其老项目),但长远看绝对值得。相比之下,hash 方案太死板,纯白名单又太脆弱,只有 strict-dynamic 能兼顾安全性和灵活性。

当然,也不是完美无缺。比如某些第三方服务(像老版 Google Tag Manager)硬编码了内联脚本,这时候你只能妥协:要么说服他们升级,要么单独给那个域名开白名单。但这种情况越来越少,大部分现代 SDK 都支持动态加载了。

最后叨叨两句

以上是我折腾 strict-dynamic 后的真实体验。可能有人觉得“加个 nonce 太麻烦”,但比起半夜被安全警报叫醒,这点麻烦真不算啥。CSP 本来就是防君子不防小人,但至少能让自动化攻击脚本失效,值了。

以上是我个人对 strict-dynamic 几种方案的对比总结,有更优的实现方式欢迎评论区交流。如果你也在搞 CSP,不妨试试 nonce + strict-dynamic,说不定能少掉几根头发。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论