前端安全加固:子资源完整性机制详解与实战

539 阅读6分钟

视频版

www.bilibili.com/video/BV1LP…

前言

hello 大家好,我是hockor,一般情况下,为了提高网页的响应速度以及性能,我们通常会JS/CSS等资源放到CDN上。比如国内的阿里云、腾讯云、七牛云等等。这里就会有一个问题,如果我们存储在云服务厂商的资源被恶意篡改了,那我们应该如何才能知道并且防范呢?

SRI 就是应对这个问题的一个解决方案。SRI 全称 Subresource Integrity - 子资源完整性,是指浏览器通过验证资源的完整性(通常从 CDN 获取)来判断其是否被篡改的安全特性。 MDN 地址: developer.mozilla.org/zh-CN/docs/…

那么具体是通过什么方式来解决的呢?首先对于一个文件,我们如何知道这个文件的内容有没有被篡改呢?我们可以对这个文件进行一个哈希计算然后通过base64编码生成一段跟文件内容关联的唯一的字符串。如果文件的内容发生了变化,那么通过相同的方式生成的字符串,跟原来的文件生成的字符串是不一样的。这样我们就知道文件被篡改了。

<script
  src="https://cdn.example.com/library.js"
  integrity="sha384-abc123xyz456..."
  crossorigin="anonymous">
</script>

综上,一句话总结:

SRI 通过校验文件哈希值,确保你加载的是预期的资源,而不是被篡改的版本。

这个标准由 W3C 和 WHATWG 推动,目前已经被主流浏览器广泛支持。

使用SRI的基础Demo

我们创建一个最简单的 HTML 页面和 JS 资源

$ tree
.
├── app.js
└── index.html

2. 生成资源的SRI哈希

cat app.js | openssl dgst -sha384 -binary | openssl base64 -A

3. 前端HTML代码

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>SRI Demo</title>
</head>
<body>
  <h1>Subresource Integrity Demo</h1>
  <script
    src="app.js"
    integrity="sha384-上面生成的hash"
    crossorigin="anonymous"></script>
</body>
</html>

如果我们的内容没有被篡改,那么我们可以正常的打开页面,并看到 JS 里的代码被执行,

如果 JS 代码有被篡改,那么控制台就会报错如下,并且 JS 不会被执行

在构建工具中使用SRI

ok,上面我们是手动生成了资源 Hash,并添加到了 HTML 中,接下来我们试着在目前主流的 Vite 和 WebPack 中利用插件来实现这个功能。

1. 在Vite中使用

在 vite 中,可以使用vite-plugin-sri插件:

// vite.config.js
import { defineConfig } from 'vite';
import sri from 'vite-plugin-sri';

export default defineConfig({
  plugins: [
    sri({
      algorithms: ['sha384'],
    })
  ]
});

2. 在Webpack中使用

相应的,webpack 也有自己的插件webpack-subresource-integrity

// webpack.config.js
const SubresourceIntegrityPlugin = require('webpack-subresource-integrity').SubresourceIntegrityPlugin;

module.exports = {
  output: {
    crossOriginLoading: 'anonymous',
  },
  plugins: [
    new SubresourceIntegrityPlugin({
      hashFuncNames: ['sha384'],
      enabled: true,
    }),
  ],
};

上面 2 个构建工具的插件使用都比较简单,这里咱们就不再赘述了。

SRI校验失败的处理

我们都知道,script 标签有一个 onerror 的回调,那么我们是否可以利用这一点来帮助我们在加载失败的时候做一些额外的处理呢,答案可以是可以的,我们做个小 demo

<script
  type="text/javascript"
  src="//example.com/script.js"
  integrity="sha256-xxx sha384-yyy"
  crossorigin="anonymous"
  onerror="loadScriptError.call(this, event)"
  onsuccess="loadScriptSuccess"
></script>
(function () {
  function loadScriptError (event) {
    // 上报错误
    // 重新加载资源
    return new Promise(function (resolve, reject) {
      var script = document.createElement('script')
      script.src = this.src.replace('//example.com', 'https://cdn.backup.com')
      script.onload = resolve
      script.onerror = reject
      script.crossOrigin = 'anonymous'
      document.head.appendChild(script)
    })
  }

  function loadScriptSuccess () {
    // 上报成功
  }

  window.loadScriptError = loadScriptError
  window.loadScriptSuccess = loadScriptSuccess
})();

3. 在Webpack中注入onerror事件

使用script-ext-html-webpack-plugin插件来注入onerroronsuccess事件。

const ScriptExtHtmlWebpackPlugin = require("script-ext-html-webpack-plugin");

module.exports = {
  plugins: [
    new ScriptExtHtmlWebpackPlugin({
      custom: {
        test: /\/*_[A-Za-z0-9]{8}.js/,
        attribute: "onerror",
        value: "loadScriptError.call(this, event)"
      }
    }),
    new ScriptExtHtmlWebpackPlugin({
      custom: {
        test: /\/*_[A-Za-z0-9]{8}.js/,
        attribute: "onsuccess",
        value: "loadScriptSuccess.call(this, event)"
      }
    })
  ]
};

上面是通过 script 标签里加入 onerror 回调来实现的,但是在一些现代浏览器中,受到 CSP 安全策略的影响,有些不再允许使用这种方式了,所以我们也可以通过全局监听 error 事件来判断是否是 sri 请求报错,这里我简单写了一个 webpack 的插件。代码如下:

// 自定义插件,为包含SRI属性的脚本添加错误处理
class ScriptAttributesPlugin {
  constructor (options = {}) {
    this.options = options
  }
  apply (compiler) {
    compiler.hooks.compilation.tap('ScriptAttributesPlugin', compilation => {
      // 添加错误处理脚本到HTML head中
      compilation.hooks.processAssets.tap(
        {
          name: 'ScriptAttributesPlugin',
          stage: compilation.constructor.PROCESS_ASSETS_STAGE_OPTIMIZE_INLINE
        },
        assets => {
          // 找到HTML文件
          const htmlAssets = Object.keys(assets).filter(key =>
            key.endsWith('.html')
          )

          htmlAssets.forEach(htmlFilename => {
            let content = assets[htmlFilename].source().toString()

            // 添加CSP友好的处理脚本
            const errorHandlingScript = `
                <script id="sri-fallback-handler">
                  // 注册一个全局的SRI错误处理函数
                  window.SRI_HANDLERS = {
                    handleError: function(errorEvent) {
                      var script = errorEvent.target || errorEvent.srcElement;
                      console.error('SRI验证失败:', script.src);
                      
                      // 创建一个没有integrity属性的新脚本
                      var newScript = document.createElement('script');
                      newScript.src = script.src;
                      newScript.crossOrigin = "anonymous";
                      
                      // 复制其他属性
                      for (var i = 0; i < script.attributes.length; i++) {
                        var attr = script.attributes[i];
                        if (attr.name !== 'integrity' && attr.name !== 'onerror') {
                          newScript.setAttribute(attr.name, attr.value);
                        }
                      }
                      
                      console.log('正在从源站加载:', newScript.src);
                      
                      // 替换当前脚本
                      if (script.parentNode) {
                        script.parentNode.replaceChild(newScript, script);
                      }
                      
                      return true; // 阻止默认错误处理
                    },
                    
                    handleSuccess: function(event) {
                      var script = event.target || event.srcElement;
                      console.log('SRI验证成功:', script.src);
                    }
                  };
                  
                  // 注册脚本加载事件监听器
                  document.addEventListener('error', function(event) {
                    if (event.target.tagName === 'SCRIPT' && event.target.hasAttribute('integrity')) {
                      window.SRI_HANDLERS.handleError(event);
                    }
                  }, true);
                  
                  document.addEventListener('load', function(event) {
                    if (event.target.tagName === 'SCRIPT' && event.target.hasAttribute('integrity')) {
                      window.SRI_HANDLERS.handleSuccess(event);
                    }
                  }, true);
                </script>
              `

            // 添加到头部
            content = content.replace(
              '</head>',
              errorHandlingScript + '</head>'
            )

            // 更新资源内容
            compilation.updateAsset(
              htmlFilename,
              new compilation.compiler.webpack.sources.RawSource(content)
            )
          })
        }
      )
    })
  }
}

module.exports = ScriptAttributesPlugin

代码里面有详细的解释,这里就不再赘述了,我们可以通过 document.addEventListener('error', fn)的方式去监听资源中是否有 integrity 属性,从而判断是否是资源报错,如果有报错的话我们可以替换一个源加载,或者是不加 integrity 的检查。这里大家可以自己根据实际场景抉择。

几点注意

好了,最后我们补充几点 SRI 使用的一些注意事项

  1. SRI支持动态请求吗?例如fetch

SRI目前只支持HTML中的<script><link>标签加载的资源,不支持动态请求。

  1. 如果使用多个哈希算法,浏览器会如何处理?

浏览器会选择安全性最高的算法(如sha512),如果该算法的哈希值不匹配,其他算法的哈希值将被忽略。

  1. SRI是否支持更多标签?

目前SRI支持<script><link>标签,未来可能支持<audio>, <embed>, <iframe>, <img>等标签。

SRI的优势与缺点

最后让我们再来简单总结一下 SRI 的优缺点

优势

  • 提升安全性:防止加载被篡改的资源。
  • 简单易用:只需在HTML中添加integritycrossorigin属性。
  • 性能无明显损耗:校验计算开销很小。
  • 广泛支持:现代浏览器全面支持。

缺点

  • 更新资源时需要重生成哈希:每次资源改动时需要同步更新HTML中的integrity值。
  • 不适合动态资源:不适用于频繁变化的内容。
  • 需要额外构建步骤:建议在CI/CD流程中加入SRI插件。

ok,以上就是今天分享的内容,希望能帮助你在前端安全上提供一些帮助和思路,我们下次再见啦~