Web页面子资源完整性校验详细指南

前言

本文转载于Web页面子资源完整性校验详细指南,对作者表示感谢。

文章内容有部分修改,但整体效果相同。

这次要跟大家分享的是关于Subresource Integrity(子资源完整性)的内容。如果平时对Web安全关注不是很多的话,可能没怎么听过这个术语。不知道也没关系,接下来我会跟大家一起来研究讨论一下这个内容,相信在看完这篇文章之后,你能够深入的理解什么是SRI,为什么要使用SRI,以及在有这方面需求的情况下如何在项目中实践使用SRI

SRI是什么,以及解决了什么问题

SRISubresource Integrity的简写,表示的是子资源的完整性。比如对于我们在页面中通过linkscript标签引入的样式文件或者引入使用的第三方库就是页面的的子资源。比如像下面这样:

1
2
3
4
5
6
<link rel="stylesheet" 
href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css"
crossorigin="anonymous">

<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"
crossorigin="anonymous"></script>

一般情况下,为了提高网页的响应速度以及性能,我们通常会把这些子资源放到CDN上。对于大的互联网公司来说,一般会有自己的云服务,也基本会有自己的CDN服务。但是对于小公司来说,一般会使用云服务厂商提供的CDN功能。这里就会有一个问题,如果我们托管在云服务厂商的CDN上的资源万一被篡改了,那么就会对我们的业务产生一些影响。虽然这种事情一般情况下不会发生,但是如果我们的业务对安全要求很高的话,那么还是要对这种情况做好防范处理。

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

CA证书的数字签名的验证原理与上述基本类似

对于每一个引入的第三方资源,我们只需要在对应的标签上加上integrity属性,integrity属性的值是一个字符串,形式如下面这样:

1
2
3
<script src="https://example.com/example-framework.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"></script>

其中sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC就是integrity的值,这个字符串以sha384开头,表示的是对应安全散列算法的名称,还有sha256sha512;接着是一个短横线-,用来分割算法名称和后面通过这个算法生成的base64编码的值;最后的oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC表示的就是对应的文件经过计算后产生的字符串。

当浏览器下载了带有integrity属性的子资源的时候,不会立刻执行里面的代码;或者应用里面的样式。浏览器会首先根据integrity属性值中指定的相应算法以及下载的文件的内容计算一下这个文件的哈希值是否跟标签中的那个值一样,只有两者一样的情况下才会应用对应的样式或者执行相应的代码。如果两者不一样,那么浏览器就会拒绝执行对应的代码,以及拒绝应用对应的样式。也会在控制台报错,提醒我们当前下载的子资源存在问题

这样我们就通过SRI这种方法保证了我们的页面不会使用从CDN上下载的被篡改了内容的资源。保证了我们的页面的安全。

如何使用SRI

上面简单介绍了SRI的作用,那么具体怎么实践呢?下面我们来一起实践一下如何使用SRI

首先我们随便创建一个index.html,然后在里面添加如下内容:

1
2
3
<script src="http://localhost:3000/test.js"
integrity="sha384-yGduQba2SOt4PhcoqN6zsgbwhbpK8ZBguLWCSdnSRc6zY/MmfJEmBguDBXJpvXFg"
crossorigin="anonymous"></script>

然后创建一个test.js文件,里面内容如下:

1
console.log('Hello world');

然后在本地使用Node.jsexpress框架或者其他的工具,让test.js能够在本地通过http://localhost:3000/test.js访问。

对于上面script标签的integrity属性,我们可以通过如下的命令,通过openssl工具获取对应的sha384算法生成的字符串:

1
cat test.js | openssl dgst -sha384 -binary | openssl base64 -A

如果是Windows环境的话,需要使用另外的方式获取对应的字符串。

然后在浏览器中打开index.html,你会看到控制台打印:Hello World。如果我们这个时候把test.js的内容更改一下,在原来的基础上,在Hello World后面加上感叹号,如下所示:

1
console.log('Hello world!');

那么这个时候控制台就不再展示Hello World,并且控制台会报错,不过不同的浏览器报错信息不一样:

  • Chrome报错如下:

  • Firefox报错如下:

  • Safari报错如下:

总之都会提醒你,当前下载的子资源通过计算后的哈希字符串,跟标签上的不一致,浏览器拒绝执行对应的代码

这里还有一些需要注意的地方,如果我们的test.js资源跟我们的index.html是不同的源,那么还需要在标签上添加crossorigin="anonymous",表明这个资源的请求是需要跨源资源共享的。不然浏览器会报错如下

如果对跨源资源共享还不是很明白的同学可以参考Cross-Origin Resource Sharing

当然对应的服务端也需要设置对应的响应头部:Access-Control-Allow-Origin: *,如果是使用express的话,可以使用cors来简单的设置一下。具体如下所示:

1
2
3
4
5
6
7
// ...
app.use(
cors({
origin: "*",
})
);
// ...

如何在框架中使用SRI

  • 对于Vue项目来说,通过使用Vue CLI我们可以很简单的就使用这个功能。通过在vue.config.js中增加一个配置:integrity: true,我们就可以在构建的时候后看到,打包后的index.html中引入的资源都是带有integrity属性的,如下面所示:
1
2
3
4
5
6
7
8
9
<!-- ... -->
<link href="/css/app.fb0c6e1c.css" rel="stylesheet"
integrity="sha384-1Ekc46o2fTK9DVGas4xXelFNSBIzgXeLlQlipQEqYUDHkR32K9dbpIkPwq+JK6cl">
<!-- ... -->
<script src="/js/chunk-vendors.0691b6c2.js"
integrity="sha384-j7EDAmdSMZbkzJnbdSJdteOHi77fyFw7j6JeGYAf4O20/zAyQq1nJ91iweLs6NDd"></script>
<script src="/js/app.290d19ae.js"
integrity="sha384-S3skbo1aIjA4WCmQH6ltlpwMgTXWrakI5+aloQEnNKpEKRfbNyy1eq6SrV88LGOh"></script>
<!-- ... -->
  • 对于其他框架来说,如果打包工具使用的是Webpack的话,可以直接使用对应的插件webpack-subresource-integrity,相关的安装和使用说明可以参考这里

关于Integrity的一些细节

在实际的使用过程中,还有很多细节需要注意的,下面给大家再深入的介绍一下。

  • 目前使用计算资源文件哈希值的算法有sha256sha384sha512,这些都是属于SHA-2的安全散列算法
  • 目前已经不推荐使用MD5SHA-1的计算哈希值的算法

  • 首先Integrity的值可以存在多个,每个值之间使用空格分隔开

    • 如果多个值分别使用的是不同的安全散列算法,比如如下所示:
    1
    2
    3
    4
    5
    6
    7
    <script src="http://localhost:3000/test.js"
    crossorigin="anonymous"
    integrity="
    sha256-LsK9lSOT7mZ9iEbLTm9cwaKTfuBdypNn2ID1Z9g7ZPM=
    sha384-yGduQba2SOt4PhcoqN6zsgbwhbpK8ZBguLWCSdnSRc6zY/MmfJEmBguDBXJpvXFg
    sha512-2qg2xR+0XgpsowJp3VCqWFgQalU9xPbqNTV0fdM9sV9ltHSSAcHni2Oo0Woo6aj860KvFu8S1Rdwb8oxJlMJ2Q==
    "></script>

    那么这个时候浏览器是根据那个安全散列算法来进行处理的呢?还是说只要有一个匹配就可以了呢?

    答案是:浏览器首先会选择安全性最高的那个计算方式,如果是上面这个例子的话,浏览器会选择sha512这种计算哈希值的算法。因为sha512的安全性大于sha384sha384的安全性大于sha256,然后会忽略掉其余通过其他方式计算出的哈希值。这个时候需要注意的是,如果浏览器根据sha512计算出来的哈希字符串跟提供的不一样的话,那么不管sha384或者sha256提供的哈希值是否正确,浏览器都会认为这个资源计算出来的哈希值跟提供的哈希值不一样。所以不会执行对应的代码。

    • 如果多个值分别使用的是相同的安全散列算法,比如如下所示:
    1
    2
    3
    4
    5
    6
    7
    <script src="http://localhost:3000/test.js"
    crossorigin="anonymous"
    integrity="
    sha384-yGduQba2SOt4PhcoqN6zsgbwhbpK8ZBguLWCSdnSRc6zY/MmfJEmBguDBXJpvXFg
    sha384-c+xXeW2CdZ1OuDKSrMpABg4MrVFWi3N5VKDC6CTgSRRnPr0dgprowjuFPomHgXlI
    sha384-E6ULLMoeKAMASZMjQ00AvU+3GzK8HPRhL/bM+P4JdcHLbNqGzU14K9ufSPJCnuex
    "></script>

    那么这个时候只要有一个值跟浏览器计算的结果是一样的,那么这个资源就可以被认为是没有被篡改的;资源的内容是可以被执行的。

  • Integrity属性暂时只支持linkscript标签,以后会支持更多的关于子资源的标签,比如:audioembediframeimg

总结

关于Web页面子资源完整性校验的分享到这里也就算结束了,相信如果大家仔细看过一遍的话应该都会有一些收获的。如果大家看完后有什么建议和反馈都可以在评论区留言。


关于crossorigin

使用window.onerror事件来捕获 js脚本中的错误信息是重要的手段 。

但是对于跨域的资源 ,onerror事件通常会上报 “Script error”

1
2
3
4
5
6
7
8
<script>
window.onerror = function(e) {
console.log(e);
}
</script>
<script
src="http://localhost:3000/test.js"
></script>
1
2
//test.js
console.log('Hello world', a);

控制台打印错误信息如下:

解决这个问题的一个办法之一就是在跨域资源的script标签中添加crossorigin属性:

1
2
3
4
5
6
7
8
9
<script>
window.onerror = function(e) {
console.log(e);
}
</script>
<script
src="http://localhost:3000/test.js"
crossorigin="anonymous"
></script>

此时控制台打印的错误信息为:

参考