Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

前端启用SRI策略 #2

Open
rainjay opened this issue Aug 9, 2017 · 2 comments
Open

前端启用SRI策略 #2

rainjay opened this issue Aug 9, 2017 · 2 comments
Labels

Comments

@rainjay
Copy link
Owner

rainjay commented Aug 9, 2017

SRI是什么?

SRI是Subresource Integrity的缩写,一般按照字面意义翻译为:子资源完整性。详情请参考:https://www.w3.org/TR/SRI/

点击查看github的页面源码,可以看到这些代码:

<link crossorigin="anonymous" href="https://assets-cdn.github.com/assets/frameworks-77c3b874f32e71b14cded5a120f42f5c7288fa52e0a37f2d5919fbd8bcfca63c.css" integrity="sha256-d8O4dPMucbFM3tWhIPQvXHKI+lLgo38tWRn72Lz8pjw=" media="all" rel="stylesheet" />
<script async="async" crossorigin="anonymous" integrity="sha256-O0RXCN8H5xVoHnaRW5G3YbcevQUfBlFdDvDgom+xgG0=" src="https://assets-cdn.github.com/assets/github-3b445708df07e715681e76915b91b761b71ebd051f06515d0ef0e0a26fb1806d.js">

上面标签里都有intergrity属性,比如:integrity="sha256-d8O4dPMucbFM3tWhIPQvXHKI+lLgo38tWRn72Lz8pjw="
这里的hash值是根据资源内容通过特定的签名算法(支持 sha256、sha384 和 sha512)生成的签名。

浏览器拿到资源内容以后,会根据integrity所指定的签名算法计算结果与integrity值进行对比。如果二者不一致,就不会执行这个资源,这个过程称为验签。(了解数字签名情查看数字签名是什么

SRI的用途

SRI可以确保页面引入资源的完整性,这样我们可以做到:

  1. 确保安全:假如CDN服务被入侵,文件内容被写入恶意代码的时候,如果启用了SRI策略,有恶意代码的文件无法执行,防止XSS攻击。虽然https也可以确保传输过程中不被劫持并写入恶意代码,但是对于CDN服务被入侵时,https也无济于事。

  2. 确保资源无脏数据:假如CDN在同步过程中有脏数据,JS代码与源站代码不一致,便不会运行。这样可以防止运行脏代码导致报错,检测到错误的同时可以向源站下载对应的正常代码。

1.通过SRI确保下载正确完整JS资源

传统的方法,是在JS正常运行的最后,加一个全局的变量来判断其是否正常运行。比如:

<script>
  function _loadScript(src) {
    const script = document.createElement('script')
    script.src = src
    script.async = false
    document.querySelector('head').appendChild(script)
  }
</script>
  
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.5.1/jquery.min.js"></script>
<script>window.jQuery || _loadScript("./js/libs/jquery-1.5.1.min.js")</script>

但使用这种办法,在遇到有问题的JS资源执行过程中,会抛出异常。但是我们通过启用SRI策略就可以避免这种问题。

我们通过使用webpack的html-webpack-plugin和webpack-subresource-integrity两个插件,我们可以轻松地生成包含integrity属性script的index.html

var path = require('path')
var webpack = require('webpack')
var ExtractTextPlugin = require('extract-text-webpack-plugin')
var HtmlWebpackPlugin = require('html-webpack-plugin');
var SriPlugin = require('webpack-subresource-integrity');
 
var webpackConfig = {
  entry: {
    app: ['./src/app.js']
  },
  output: {
    path: path.join(__dirname, 'dist'),
    filename: '[name].[chunkhash:8].js',
    crossOriginLoading: 'anonymous',
    publicPath: '//www.cndjs.com/' //这里配置的是CDN地址,在下面通过script写入的方法去loadScript的时候,应该去掉该配置
  },
  module: {
    rules: [{
      test: /\.(css)$/,
      use: ExtractTextPlugin.extract({
        fallback: 'style-loader',
        use: [{
          loader: 'css-loader',
          options: {
            sourceMap: true
          }
        }]
      })
    }]
  },
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: "vendor",
      minChunks: function (module) {
        // this assumes your vendor imports exist in the node_modules directory
        return module.context && module.context.indexOf("node_modules") !== -1;
      }
    }),
    new webpack.optimize.CommonsChunkPlugin({
      name: 'manifest'
    }),
    new webpack.HashedModuleIdsPlugin(),
    new ExtractTextPlugin({
      filename: '[name].[contenthash:6].css',
      allChunks: true
    }),
    new HtmlWebpackPlugin({
      filename: 'index.html',
      template: 'src/template.html',
      inject: false, //这里应该填false,手动插入script标签
      chunksSortMode: 'dependency'
    }),
    new SriPlugin({
      hashFuncNames: ['sha256'],
      enabled: true
    })
  ]
}

下面是template.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
  <% for (var css in htmlWebpackPlugin.files.css) { %>
  <link href="<%= htmlWebpackPlugin.files.css[css] %>" integrity="<%= htmlWebpackPlugin.files.cssIntegrity[css] %>" crossorigin="anonymous"
    rel="stylesheet">
  <% } %>
</head>
<body>
<% for (var chunk in htmlWebpackPlugin.files.js) { %>
  <script src="<%= htmlWebpackPlugin.files.js[chunk] %>" integrity="<%= htmlWebpackPlugin.files.jsIntegrity[chunk] %>"
    crossorigin="anonymous"></script>
  <% } %>
</body>
</html>

注意:SRI只是保证在验签失败的时候不执行对应的资源!而我们希望对应的资源加载不正确的时候去源站下载对应的代码。并且,我们还希望JS代码可以按照预期的加载顺序来执行(如果manifest.js加载失败,依赖它的vendor.js却加载成功了,vendor.js在执行的过程中就会报错)。

2.SRI检测到CDN资源不完整时,去下载源站代码

我们知道,通过script标签加载到的JS默认会严格按照标签顺序去执行的,但是通过createElement('script')并append到body中的JS资源,执行却是先下载完成先执行!

为了让代码可以按照顺序执行,我们必须要控制JS代码的加载顺序,保证在一个JS加载运行成功后再加载下一个,所以代码可以这么改:

<!DOCTYPE html>
<html lang="en">
 
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>
 
<body>
  <script>
    var CDN_URI = '//cdnjs.com/'
    var styles = {}
    <% for (var css in htmlWebpackPlugin.files.css) { %>
      styles["<%= htmlWebpackPlugin.files.css[css] %>"] = "<%= htmlWebpackPlugin.files.cssIntegrity[css] %>"
    <% } %>
  
    var chunks = {}
    <% for (var chunk in htmlWebpackPlugin.files.js) { %>
      chunks["<%= htmlWebpackPlugin.files.js[chunk] %>"] = "<%= htmlWebpackPlugin.files.jsIntegrity[chunk] %>"
    <% } %>
 
    Object.keys(styles).forEach(function (key) {
      _loadStyle(CDN_URI + key, styles[key])
        .catch(function () {
          return _loadStyle(key, styles[key])
        })
    })
 
    Object.keys(chunks).reduce(function (pre, cur) {
      return pre.then(function() {
        return _wrapLoadScript(cur)
      })
    }, Promise.resolve())
 
    function _wrapLoadScript(key) {
      return _loadScript(CDN_URI + key, chunks[key])
        .catch(function () {
          return _loadScript(key, chunks[key])
        })
    }
 
    function _loadScript(src, intergrity) {
      return new Promise(function (resolve, reject) {
        var script = document.createElement('script')
        script.src = src
        script.onload = resolve
        script.onerror = reject
        if (intergrity) {
          script.intergrity = intergrity
          script.crossoirgin = 'anonymous'
        }
        document.querySelector('head').appendChild(script)
      })
    }
 
    function _loadStyle(href, intergrity) {
      return new Promise((resolve, reject) => {
        var link = document.createElement('link')
        link.rel = 'stylesheet'
        link.crossoirgin = 'anonymous'
        link.onload = resolve
        link.onerror = reject
        if (intergrity) {
          link.href = href
          link.intergrity = intergrity
        }
        document.querySelector('head').appendChild(link)
      })
    }
  </script>
</body>
</html>

以上方案可以完整解决CDN资源不完整问题,但有一个致命的问题:JS资源运行完一个才能去下载下一个,而不是并发下载,首页渲染时间会受到严重影响!

所以,在CDN能够保证百分之99.99不出问题的时候,我们可以不使用以上方法。但是,我们依旧可以做一些事情,比如: 接入报错监控系统, 在极少部分用户下载到CDN资源有问题,不能正常运行时,可以将script加载的error信息抛出,比如可以对1中的template.html进行如下修改:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
  <% for (var css in htmlWebpackPlugin.files.css) { %>
  <link href="<%= htmlWebpackPlugin.files.css[css] %>" integrity="<%= htmlWebpackPlugin.files.cssIntegrity[css] %>" crossorigin="anonymous"
    rel="stylesheet">
  <% } %>
</head>
<body>
<% for (var chunk in htmlWebpackPlugin.files.js) { %>
  <script src="<%= htmlWebpackPlugin.files.js[chunk] %>"
    integrity="<%= htmlWebpackPlugin.files.jsIntegrity[chunk] %>"
    crossorigin="anonymous"
    onerror="throw 'SRI ERROR'"> // 这里抛出的error信息‘SRI ERROR'会被perf的全局window.addEventListener('error', callback)检测到并统计在God中
  </script>
<% } %>
</body>
</html>

上面的代码,前提是要接入一个报错监控系统。这样如果CDN资源有问题,错误信息为SRI ERROR会统计在里。你可以通过IP来判断哪个地区的CDN节点有脏数据,然后让运维帮忙清理一下对应的节点即可!
这样就不至于某个CDN节点有问题,自己还完全不知道!直到客服反馈客户端白屏问题,通过远程协助各种倒腾一番才搞清楚!

总结:虽然2中的方法可以最大限度地保证系统的稳定性,但是这是以牺牲页面渲染性能为代价的。在CDN资源基本百分之99.99不出问题的情况下,不建议使用2里的办法(但也应该懂得思路)。建议使用God来做统计和预警,万一发生大规模CDN脏数据的情况,可以及时发现问题,及时解决!

3. 使用<link rel="preload"> 来并发预加载资源,提高性能

2中的问题,我们可以通过preload资源来解决(会有兼容性问题),具体实现如下:

<!DOCTYPE html>
<html lang="en">
 
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>
 
<body>
  <script>
    var CDN_URI = '//cdnjs.com/'
    var styles = {}
    <% for (var css in htmlWebpackPlugin.files.css) { %>
      styles["<%= htmlWebpackPlugin.files.css[css] %>"] = "<%= htmlWebpackPlugin.files.cssIntegrity[css] %>"
    <% } %>
  
    var chunks = {}
    <% for (var chunk in htmlWebpackPlugin.files.js) { %>
      chunks["<%= htmlWebpackPlugin.files.js[chunk] %>"] = "<%= htmlWebpackPlugin.files.jsIntegrity[chunk] %>"
    <% } %>
 
    Object.keys(styles).forEach(key => {
      _loadStyle(CDN_URI + key, styles[key])
        .catch(function () {
          return _loadStyle(key, styles[key])
        })
    })
   
    var preloadUrls = Object.keys(chunks).map(key => _wrapPreload(key))
 
    Promise.all(preloadUrls).then(urls => {
      urls.forEach(function(url) {
        _loadScript(url)
      })
    })
 
    function _wrapPreload(key) {
      return _preLoad(CDN_URI + key, chunks[key])
        .catch(link => {
          link.href = key
          return key
        })
    }
 
    function _loadScript(src) {
      var script = document.createElement('script')
      script.src = src
      script.async = false
      document.querySelector('head').appendChild(script)
    }
 
    function _loadStyle(href, intergrity) {
      return new Promise((resolve, reject) => {
        var link = document.createElement('link')
        link.rel = 'stylesheet'
        link.crossoirgin = 'anonymous'
        link.onload = resolve
        link.onerror = reject
        if (intergrity) {
          link.href = href
          link.intergrity = intergrity
        }
        document.querySelector('head').appendChild(link)
      })
    }
 
    function _preLoad(href, intergrity) {
      return new Promise(function (resolve, reject) {
        var link = document.createElement('link')
        link.href = href
        link.rel = 'preload'
        link.as = 'script'
        link.onload = resolve.bind(undefined, href)
        link.onerror = reject.bind(undefined, link)
        if (intergrity) {
          link.setAttribute('intergrity', intergrity)
          link.setAttribute('crossoirgin', 'anoymous')
        }
        document.querySelector('head').appendChild(link)
      })
    }
  </script>
</body>
</html>

如果没有兼容问题,3已经是我们想要的完美解决方案了

补充:上面script里都加了crossorigin="anonymous"这个属性,具体原因可以自行查阅资料!
代码参考:https://github.com/rainjay/subResource-intergrity

参考资料:
Subresource Integrity
CORS settings attributes
html-webpack-plugin
webpack-subresource-integrity
http://caniuse.com/#search=Subresource%20Integrity

@rainjay rainjay added the blog label Aug 9, 2017
@5201314999
Copy link

是我等级不够吗,还是因为平时不需要接触。读起来枯燥无味,记不住

@rainjay
Copy link
Owner Author

rainjay commented Oct 11, 2018

@5201314999 不用记,API哪里都查得到,理解要解决的问题和解决思路就行了

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

2 participants