使用子资源完整性保护您的网站

Avatar of Khari McMillian
Khari McMillian

DigitalOcean 为您旅程的每个阶段提供云产品。 立即开始使用 200 美元的免费额度!

当您从外部服务器加载文件时,您信任您请求的内容与您预期的一致。 由于您不自己管理服务器,因此您依赖于另一个第三方的安全性和攻击面的增加。 信任第三方本身并没有错,但在您网站的安全上下文中,当然应该予以考虑。

现实世界的例子

这并非纯粹的理论风险。 忽视潜在的安全问题可能已经并且已经导致了严重的后果。 2019 年 6 月 4 日,Malwarebytes 宣布他们发现了 NBA.com 网站上的恶意窃取器。 由于 Amazon S3 存储桶遭到入侵,攻击者能够更改 JavaScript 库以窃取客户的信用卡信息。

不仅 JavaScript 值得担心。 CSS 也是另一种能够执行危险操作(例如窃取密码)的资源,并且只需要一个被入侵的第三方服务器即可造成灾难。 但它们可以提供我们无法简单放弃的宝贵服务,例如减少网站总带宽使用量并由于基于位置的缓存而更快地向最终用户提供文件的 CDN。 因此,我们已经确定有时需要依赖我们无法控制的主机,但我们也需要确保我们从中接收到的内容是安全的。 我们能做些什么?

解决方案:子资源完整性 (SRI)

SRI 是一种安全策略,可防止加载与预期哈希不匹配的资源。 通过这样做,如果攻击者获得对文件的访问权限并修改其内容以包含恶意代码,则它将与我们预期的哈希不匹配,并且根本不会执行。

HTTPS 不是已经这样做了么?

HTTPS 非常适合安全,并且是任何网站的必备条件,虽然它确实可以防止类似的问题(以及更多问题),但它只能防止在传输过程中篡改数据。 如果文件在主机本身上被篡改,则恶意文件仍将通过 HTTPS 发送,无法阻止攻击。

散列是如何工作的?

散列函数将任何大小的数据作为输入,并返回固定大小的数据作为输出。 散列函数理想情况下应该具有均匀分布。 这意味着对于任何输入 x,输出 y 将是任何特定可能值的概率类似于它在输出范围内的任何其他值的概率。

这是一个隐喻

假设您有一个 6 面骰子和一个姓名列表。 在这种情况下,姓名将是散列函数的“输入”,而掷出的数字将是函数的“输出”。 对于列表中的每个名称,您都会掷骰子并跟踪每个数字对应哪个名称,方法是在名称旁边写下数字。 如果一个名称作为输入使用多次,则其对应的输出将始终是第一次的值。 对于第一个名称 Alice,您掷出 4。对于下一个名称 John,您掷出 6。然后对于 Bob、Mary、William、Susan 和 Joseph,您分别得到 2、2、5、1 和 1。 如果您再次使用“John”作为输入,则输出将再次为 6。这个隐喻本质上描述了散列函数的工作原理。

姓名(输入)掷出的数字(输出)
Alice4
John6
Bob2
Mary2
William5
Susan1
Joseph1

您可能已经注意到,例如,Bob 和 Mary 具有相同的输出。 对于散列函数,这称为“冲突”。 对于我们的示例场景,它不可避免地会发生。 由于我们有七个名称作为输入,并且只有六个可能的输出,因此我们保证至少发生一次冲突。

此示例与实践中散列函数的一个显着区别在于,实际的散列函数通常是确定性的,这意味着它们不像我们的示例那样使用随机性。 相反,它可预测地将输入映射到输出,以便每个输入都同样可能映射到任何特定输出。

SRI 使用称为安全散列算法 (SHA) 的散列函数系列。 这是一个加密散列函数系列,包括 128、256、384 和 512 位变体。 加密散列函数是一种更具体的散列函数,其属性实际上不可能反转以找到原始输入(如果没有相应的输入或暴力破解)、抗冲突,并且设计为输入的微小变化会改变整个输出。 SRI 支持 SHA 系列的 256、384 和 512 位变体。

这是一个使用 SHA-256 的示例

例如,hello 的输出是

2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824

hell0(用零代替 O)的输出是

bdeddd433637173928fe7202b663157c9e1881c3e4da1d45e8fff8fb944a4868

您会注意到,输入的微小变化会产生完全不同的输出。 这是前面列出的加密哈希的属性之一。

您最常看到的哈希格式是十六进制,它包含所有十进制数字 (0-9) 和字母 A 到 F。 此格式的优点之一是每两个字符表示一个字节,并且均匀性对于诸如颜色格式之类的用途很有用,其中一个字节表示每种颜色。 这意味着没有 alpha 通道的颜色只能用六个字符表示(例如,红色 = 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-2562563264
SHA-3843844896
SHA-51251264128

对于 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 设置一个名为 bits 的变量为 384。稍后脚本中会用到它。
  • openssl dgst -sha%bits% -binary %1% | openssl base64 -A > tmp 比较复杂,让我们将其分解成几个部分。
    • openssl dgst 计算输入文件的摘要。
    • -sha%bits% 使用变量 bits,并将其与字符串的其余部分组合,形成可能的标志值之一,例如 sha256sha384sha512
    • -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.js3frxDlOvLa6GGEUwMh9AowcepHRx/rwFT9VW9yL1wv/OcerR39FEfAUHZRrqaOy2
file2.jshtr1LmWx3PQJIPw5bM9kZKq/FL0jMBuJDxhwdsMHULKybAG5dGURvJIXR9bh5xJ9

然后,让我们创建一个名为 index.html 的文件

<!DOCTYPE html>
<html>
  <head>
    <script type="text/javascript" src="./file1.js" crossorigin="anonymous"></script>
    <script type="text/javascript" src="./file2.js" crossorigin="anonymous"></script>
  </head>
</html>

将所有这些文件放在同一个文件夹中,并在该文件夹内启动一个服务器(例如,在包含这些文件的文件夹中运行 npx http-server,然后打开 http-server 提供的地址之一或您选择的服务器,例如 127.0.0.1:8080)。您应该会看到两个警报对话框。第一个应该显示“Hello, world!”,第二个显示“Hi, world!”。

如果您修改脚本的内容,您会注意到它们不再执行。这是子资源完整性生效的结果。浏览器注意到请求的文件的哈希值与预期哈希值不匹配,并拒绝运行它。

我们还可以为一个资源包含多个哈希值,浏览器将选择最强的哈希值,如下所示

<!DOCTYPE html>
<html>
  <head>
    <script
      type="text/javascript"
      src="./file1.js"
       crossorigin="anonymous"></script>
    <script 
      type="text/javascript"
      src="./file2.js"
      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 [文件夹名称] 为其命名。然后输入 cd [文件夹名称] 进入该文件夹。现在我们需要将目录设置为 Node 项目,因此输入 npm init。它会问你一些问题,但你可以按 Enter 跳过它们,因为它们与我们的示例无关。

Webpack

Webpack 是一个库,允许您自动将文件组合成一个或多个捆绑包。使用 Webpack,我们不再需要手动更新哈希值。相反,Webpack 将资源注入到 HTML 中,其中包含 integritycrossorigin 属性。

安装 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-pluginwebpack-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=…>)中插入子资源完整性信息。
style-loader应用我们导入的 CSS 样式。
css-loader使我们能够将 css 文件 import 到我们的 JavaScript 中。

设置配置

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 ...
      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 和子资源完整性信息注入到哪里。创建一个名为 index.ejs 的文件。

<!DOCTYPE html>
<html>
  <body></body>
</html>

现在,在文件夹中创建一个 index.js,包含以下脚本。

// Imports the CSS stylesheet
import './styles.css'
alert('Hello, world!');

构建 bundle

在终端中输入 npm run build。你会注意到会创建一个名为 dist 的文件夹,并在其中找到一个名为 index.html 的文件,其内容类似于以下示例。

<!DOCTYPE HTML>
<html><head><script defer src="bundle.js" crossorigin="anonymous">
</script></head>
  <body>
  </body>
</html>

CSS 将作为 bundle.js 文件的一部分包含在内。

这对于从外部服务器加载的文件不起作用,也不应该起作用,因为需要不断更新的跨源文件在启用子资源完整性时会中断。

感谢阅读!

这就是全部内容。子资源完整性是一个简单有效的补充,可以确保您只加载预期内容并保护您的用户;请记住,安全性不仅仅是一个解决方案,因此请始终寻找更多方法来确保您的网站安全。