当你从外部服务器加载一个文件时,你相信你请求的内容是你所期望的。由于你没有自己管理服务器,你在依赖另一个第三方的安全,并增加了攻击面。信任第三方本身并不是坏事,但它肯定应该在你的网站安全方面被考虑到。
一个现实世界的例子
这并不是一个纯粹的理论上的危险。忽视潜在的安全问题可能并且已经导致了严重的后果。2019年6月4日,Malwarebytes宣布他们在NBA.com网站上发现了一个恶意的盗版软件。由于一个受损的亚马逊S3桶,攻击者能够改变一个JavaScript库,以窃取客户的信用卡信息。
值得担心的也不仅仅是JavaScript。CSS是另一种能够执行危险行动的资源,如密码窃取,只需要一个被破坏的第三方服务器就能带来灾难。但是他们可以提供我们不能没有的宝贵服务,例如CDN,它可以减少网站的总带宽使用量,并且由于基于位置的缓存,可以更快地将文件提供给终端用户。因此,我们有时需要依赖一个我们无法控制的主机,这是确定的,但我们也需要确保我们从它那里收到的内容是安全的。我们能做什么呢?
解决方案:子资源完整性(SRI)
SRI是一种安全策略,可以防止加载不符合预期哈希值的资源。通过这样做,如果攻击者获得一个文件的访问权,并修改其内容以包含恶意代码,它就不会与我们所期望的哈希值相匹配,而根本不会执行。
HTTPS不是已经这样做了吗?
HTTPS对于安全来说是很好的,是任何网站都必须具备的,虽然它确实可以防止类似的问题(以及更多的问题),但它只保护传输中的数据不被篡改。如果一个文件在主机上被篡改,恶意文件仍然会通过HTTPS发送,对防止攻击毫无作用。
散列是如何工作的?
散列函数接受任何大小的数据作为输入,并返回固定大小的数据作为输出。散列函数最好有一个统一的分布。这意味着对于任何输入,x ,输出,y ,是任何特定的可能值的概率与它是输出范围内任何其他值的概率相似。
这里有一个比喻。
假设你有一个6面的骰子和一串名字。在这种情况下,这些名字是哈希函数的 "输入",掷出的数字是函数的 "输出"。对于列表中的每个名字,你都要掷骰子,并通过在名字旁边写上数字来记录每个数字所对应的名字。如果一个名字被多次用作输入,其对应的输出将始终是第一次时的情况。对于第一个名字,Alice,你掷4。对于下一个名字,约翰,你掷6。然后对于鲍勃、玛丽、威廉、苏珊和约瑟夫,你分别得到2、2、5、1和1。如果你再次使用 "约翰 "作为输入,输出将再次为6。这个比喻从本质上描述了哈希函数的工作原理。
| 名称(输入) | 滚动的数字(输出) |
|---|---|
| 爱丽丝 | 4 |
| 约翰 | 6 |
| 鲍勃 | 2 |
| 玛丽 | 2 |
| 鲍勃 | 5 |
| 苏珊 | 1 |
| 约瑟夫 | 1 |
你可能已经注意到,例如,鲍勃和玛丽有相同的输出。对于散列函数,这被称为 "碰撞"。对于我们的例子场景,它不可避免地发生了。因为我们有七个名字作为输入,而只有六个可能的输出,我们保证至少有一次碰撞。
这个例子和实践中的哈希函数之间的一个明显区别是,实际的哈希函数通常是确定性的,这意味着它们不会像我们的例子那样利用随机性。相反,它可以预测地将输入映射到输出,这样每个输入都同样可能映射到任何特定的输出。
SRI使用了一个叫做安全散列算法(SHA)的散列函数系列。这是一个加密散列函数系列,包括128、256、384和512位变体。加密哈希函数是一种更特殊的哈希函数,其特性是实际上不可能逆向找到原始输入(如果没有相应的输入或暴力强化),抗碰撞,并且设计成输入的微小变化会改变整个输出。SRI支持SHA系列的256、384和512位变体。
这里有一个使用SHA-256的例子。
例如,hello 的输出是:
2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
而hell0 (用0代替O)的输出结果是:
bdeddd433637173928fe7202b663157c9e1881c3e4da1d45e8fff8fb944a4868
你会注意到,输入中最轻微的变化都会产生一个完全不同的输出。这就是前面列出的加密哈希值的特性之一。
你最常看到的哈希值格式是十六进制,它由所有十进制数字(0-9)和字母A到F组成。这种格式的好处之一是每两个字符代表一个字节,这种均匀性对于诸如颜色格式化的目的很有用,在这种格式中,一个字节代表每种颜色。这意味着一种没有阿尔法通道的颜色可以只用六个字符来表示(例如,红色=ff0000)
这种空间效率也是我们使用散列而不是每次将文件的全部内容与我们所期望的数据进行比较的原因。虽然256位不能代表一个大于256位的文件中的所有数据,但SHA-256(以及384、512)的抗碰撞性确保了几乎不可能找到两个不同输入的散列值相匹配。而至于SHA-1,它不再安全,因为已经发现了碰撞。
有趣的是,紧凑性的吸引力可能是SRI哈希值不使用十六进制格式,而使用base64的原因之一。这起初可能是一个奇怪的决定,但是当我们考虑到这些哈希值将被包含在代码中,而且base64能够传达与十六进制相同的数据量,同时又比十六进制短33%,这就说得通了。base64的一个字符可以有64种不同的状态,也就是6比特的数据量,而十六进制只能表示16种状态,也就是4比特的数据量。因此,举例来说,如果我们想表示32个字节的数据(256位),我们需要64个十六进制的字符,但base64中只有44个字符。当我们使用较长的哈希值时,如sha384/512,base64节省了大量的空间。
为什么散列法对SRI有效?
让我们想象一下,有一个托管在第三方服务器上的JavaScript文件,我们把它包含在我们的网页中,并且我们为它启用了子资源完整性。现在,如果攻击者用恶意代码修改该文件的数据,其散列值将不再符合预期的散列值,该文件将不会执行。回顾一下,文件中的任何微小变化都会完全改变其对应的SHA哈希值,而且在撰写本文时,SHA-256及以上级别的哈希值碰撞几乎是不可能的。
我们的第一个SRI哈希值
因此,你可以用几种方法来计算文件的SRI哈希值。一种方法(也许是最简单的)是使用srihash.org,但如果你喜欢更有程序性的方法,你可以使用:
sha384sum [filename here] | head -c 96 | xxd -r -p | base64
sha384sum计算一个文件的SHA-384哈希值head -c 96对输入的字符串中除前96个字符外的所有字符进行修剪-c 96表示要修剪除前96个字符以外的所有字符。我们使用96,因为它是十六进制的SHA-384哈希值的字符长度。
xxd -r -p将输入到它的十六进制文件转换为二进制文件。-r告诉xxd,接收十六进制并将其转换为二进制-p移除额外的输出格式化
base64简单地将二进制输出从xxd转换为base64
如果你决定使用这种方法,请查看下表,看看每个SHA哈希值的长度。
| 哈希算法 | 位数 | 字节 | 十六进制字符 |
|---|---|---|---|
| SHA-256 | 256 | 32 | 64 |
| SHA-384 | 384 | 48 | 96 |
| SHA-512 | 512 | 64 | 128 |
对于head -c [x] 命令,x 将是相应算法的十六进制字符数。
MDN还提到了一个计算SRI哈希值的命令:
shasum -b -a 384 FILENAME.js | awk '{ print $1 }' | xxd -r -p | base64
awk '{print $1}' 找到一个字符串的第一段(用制表符或空格隔开),并将其传递给 。 代表传递给它的字符串的第一段。xxd $1
而如果你运行的是Windows:
@echo off
set bits=384
openssl dgst -sha%bits% -binary %1% | openssl base64 -A > tmp
set /p a= < tmp
del tmp
echo sha%bits%-%a%
pause
@echo off防止正在运行的命令被显示出来。这对于确保终端不变得杂乱无章特别有帮助。set bits=384设置一个名为 的变量为384。这将在脚本中稍后使用。bitsopenssl dgst -sha%bits% -binary %1% | openssl base64 -A > tmp这是很复杂的,所以让我们把它分成几个部分。openssl dgst计算一个输入文件的摘要。-sha%bits%使用变量 ,并将其与字符串的其余部分结合起来,成为可能的标志值之一-binary将哈希值输出为二进制数据,而不是字符串格式,如十六进制。%1%是脚本运行时传递给它的第一个参数。- 该命令的第一部分对作为参数提供给脚本的文件进行散列。
| openssl base64 -A > tmp将通过它的二进制输出转换为base64,并将其写入一个名为tmp的文件。-A将base64输出到一个单行。set /p a= <tmp将该文件的内容tmp,存储在一个变量中a。del tmp删除tmp文件。echo sha%bits%-%a%将打印出SHA哈希值的类型,以及输入文件的base64。pause防止终端关闭。
行动中的SRI
现在我们了解了散列和SRI哈希的工作原理,让我们尝试一个具体的例子。我们将创建两个文件:
// file1.js
alert('Hello, world!');
和:
// file2.js
alert('Hi, world!');
然后我们将计算两个文件的SHA-384 SRI哈希值:
| 文件名 | SHA-384哈希值(base64) |
|---|---|
file1.js | 3frxDlOvLa6GGEUwMh9AowcepHRx/rwFT9VW9yL1wv/OcerR39FEfAUHZRrqaOy2 |
file2.js | htr1LmWx3PQJIPw5bM9kZKq/FL0jMBuJDxhwdsMHULKybAG5dGURvJIXR9bh5xJ9 |
然后,让我们创建一个名为index.html 的文件:
<!DOCTYPE html>
<html>
<head>
<script type="text/javascript" src="./file1.js" integrity="sha384-3frxDlOvLa6GGEUwMh9AowcepHRx/rwFT9VW9yL1wv/OcerR39FEfAUHZRrqaOy2" crossorigin="anonymous"></script>
<script type="text/javascript" src="./file2.js" integrity="sha384-htr1LmWx3PQJIPw5bM9kZKq/FL0jMBuJDxhwdsMHULKybAG5dGURvJIXR9bh5xJ9" crossorigin="anonymous"></script>
</head>
</html>
将所有这些文件放在同一个文件夹中,并在该文件夹中启动一个服务器(例如,在包含这些文件的文件夹中运行npx http-server ,然后打开http-server提供的一个地址或你选择的服务器,如127.0.0.1:8080 )。你应该得到两个警报对话框。第一个应该说 "你好,世界!",第二个说 "你好,世界!"
如果你修改了这些脚本的内容,你会发现它们不再执行。这就是子资源完整性的作用。浏览器注意到所请求的文件的哈希值与预期的哈希值不一致,并拒绝运行它。
我们还可以为一个资源包含多个哈希值,并选择最强的哈希值,就像这样:
<!DOCTYPE html>
<html>
<head>
<script
type="text/javascript"
src="./file1.js"
integrity="sha384-3frxDlOvLa6GGEUwMh9AowcepHRx/rwFT9VW9yL1wv/OcerR39FEfAUHZRrqaOy2 sha512-cJpKabWnJLEvkNDvnvX+QcR4ucmGlZjCdkAG4b9n+M16Hd/3MWIhFhJ70RNo7cbzSBcLm1MIMItw
9qks2AU+Tg=="
crossorigin="anonymous"></script>
<script
type="text/javascript"
src="./file2.js"
integrity="sha384-htr1LmWx3PQJIPw5bM9kZKq/FL0jMBuJDxhwdsMHULKybAG5dGURvJIXR9bh5xJ9 sha512-+4U2wdug3VfnGpLL9xju90A+kVEaK2bxCxnyZnd2PYskyl/BTpHnao1FrMONThoWxLmguExF7vNV
WR3BRSzb4g=="
crossorigin="anonymous"></script>
</head>
</html>
浏览器将选择被认为是最强的哈希值,并根据它检查文件的哈希值。
为什么有一个 "crossorigin "属性?
crossorigin 属性告诉浏览器何时在请求资源时发送用户凭证。有两个选项可以选择:
值 (crossorigin=) | 说明 |
|---|---|
anonymous | 该请求将其证书模式设置为same-origin ,其模式设置为cors。 |
use-credentials | 该请求将其凭证模式设置为include ,其模式设置为cors 。 |
提到的请求凭证模式
| 凭证模式 | 说明 |
|---|---|
same-origin | 证书将与发送到同源域的请求一起被发送,并且将使用从同源域发送的证书。 |
include | 证书也将被发送到跨源域,从跨源域发送的证书也将被使用。 |
提到的请求模式
| 请求模式 | 描述 |
|---|---|
cors | 请求将是一个 CORS 请求,这将要求服务器有一个定义的 CORS 策略。如果没有,该请求将抛出一个错误。 |
为什么子资源的完整性需要 "crossorigin "属性?
默认情况下,脚本和样式表可以跨源加载,由于子资源完整性在加载的资源的哈希值与预期的哈希值不一致时阻止文件的加载,攻击者可以大量加载跨源资源,并通过特定的哈希值测试加载是否失败,从而推断出用户的信息,否则他们就无法推断。
当你包含crossorigin 属性时,跨源域必须选择允许来自该请求的源头的请求,这样请求才会成功。这可以防止子资源完整性的跨源攻击。
使用webpack的子资源完整性
每次更新文件时都要重新计算每个文件的SRI哈希值,这听起来可能很费事,但幸运的是,有一种方法可以将其自动化。让我们一起看一个例子。在你开始之前,你需要一些东西。
Node.js和npm
Node.js是一个JavaScript运行时,它和npm(它的包管理器)一起,将允许我们使用webpack。要安装它,请访问Node.js网站,选择与你的操作系统相对应的下载。
设置项目
创建一个文件夹,用mkdir [name of folder] ,并给它任意命名。然后输入cd [name of folder] ,导航到其中。现在我们需要将该目录设置为一个Node项目,所以输入npm init 。它会问你几个问题,但你可以按回车键跳过这些问题,因为它们与我们的例子无关。
webpack
webpack是一个库,它允许你自动将你的文件组合成一个或多个包。有了webpack,我们将不再需要手动更新哈希值。相反,webpack会将资源注入HTML中,并包含integrity 和crossorigin 属性。
安装webpack
于需要安装webpack和webpack-cli。
npm i --save-dev webpack webpack-cli
两者之间的区别是,webpack包含核心功能,而webpack-cli是命令行界面。
我们将编辑我们的package.json ,像这样添加一个scripts 部分:
{
//... rest of package.json ...,
"scripts": {
"dev": "webpack --mode=development"
}
//... rest of package.json ...,
}
这使我们能够运行npm run dev ,并构建我们的捆绑包。
设置webpack配置
接下来,让我们来设置webpack的配置。这对于告诉webpack它需要处理哪些文件以及如何处理是必要的。
首先,我们需要安装两个软件包:html-webpack-plugin ,和webpack-subresource-integrity 。
npm i --save-dev html-webpack-plugin webpack-subresource-integrity style-loader css-loader
| 包名称 | 描述 |
|---|---|
| html-webpack-plugin | 创建一个可以将资源注入的HTML文件 |
| webpack-subresource-integrity | 计算并插入子资源完整性信息到资源中,如<script> 和<link rel=…> |
| 样式加载器 | 应用我们导入的CSS样式 |
| css-loader | 使我们能够在我们的JavaScript中import css文件。 |
设置配置:
const path = require('path'),
HTMLWebpackPlugin = require('html-webpack-plugin'),
SriPlugin = require('webpack-subresource-integrity');
module.exports = {
output: {
// The output file's name
filename: 'bundle.js',
// Where the output file will be placed. Resolves to
// the "dist" folder in the directory of the project
path: path.resolve(__dirname, 'dist'),
// Configures the "crossorigin" attribute for resources
// with subresource integrity injected
crossOriginLoading: 'anonymous'
},
// Used for configuring how various modules (files that
// are imported) will be treated
modules: {
// Configures how specific module types are handled
rules: [
{
// Regular expression to test for the file extension.
// These loaders will only be activated if they match
// this expression.
test: /\.css$/,
// An array of loaders that will be applied to the file
use: ['style-loader', 'css-loader'],
// Prevents the accidental loading of files within the
// "node_modules" folder
exclude: /node_modules/
}
]
},
// webpack plugins alter the function of webpack itself
plugins: [
// Plugin that will inject integrity hashes into index.html
new SriPlugin({
// The hash functions used (e.g.
// <script integrity="sha256- ... sha384- ..." ...
hashFuncNames: ['sha384']
}),
// Creates an HTML file along with the bundle. We will
// inject the subresource integrity information into
// the resources using webpack-subresource-integrity
new HTMLWebpackPlugin({
// The file that will be injected into. We can use
// EJS templating within this file, too
template: path.resolve(__dirname, 'src', 'index.ejs'),
// Whether or not to insert scripts and other resources
// into the file dynamically. For our example, we will
// enable this.
inject: true
})
]
};
创建模板
我们需要创建一个模板来告诉webpack要把bundle和subresource的完整性信息注入到什么地方。创建一个名为index.ejs 的文件。
<!DOCTYPE html>
<html>
<body></body>
</html>
现在,在该文件夹中创建一个index.js ,脚本如下:
// Imports the CSS stylesheet
import './styles.css'
alert('Hello, world!');
构建捆绑包
在终端输入npm run build 。你会注意到,一个名为dist 的文件夹被创建,在它里面,一个名为index.html 的文件看起来像这样:
<!DOCTYPE HTML>
<html><head><script defer src="bundle.js" integrity="sha384-lb0VJ1IzJzMv+OKd0vumouFgE6NzonQeVbRaTYjum4ql38TdmOYfyJ0czw/X1a9b" crossorigin="anonymous">
</script></head>
<body>
</body>
</html>
CSS将作为bundle.js 文件的一部分被包含。
这对从外部服务器加载的文件不起作用,也不应该起作用,因为需要不断更新的跨源文件在启用子资源完整性后会损坏。
谢谢你的阅读!
这一次就到此为止。子资源完整性是一个简单而有效的补充,可以确保你只加载你所期望的内容,并保护你的用户;记住,安全不仅仅是一个解决方案,所以要始终关注更多的方法来保持你的网站安全。