使用 SRI 解決 CDN 劫持

最近項目頻頻遇到 CDN 劫持的事情,學習到能夠經過 Subresource Integrity 的方式有效應對。javascript

SRI 簡介

SRI 全稱 Subresource Integrity - 子資源完整性,是指瀏覽器經過驗證資源的完整性(一般從 CDN 獲取)來判斷其是否被篡改的安全特性。html

經過給 link 標籤或者 script 標籤增長 integrity 屬性便可開啓 SRI 功能,好比:前端

<script type="text/javascript" src="//s.url.cn/xxxx/aaa.js" integrity="sha256-xxx sha384-yyy" crossorigin="anonymous"></script>
複製代碼

integrity 值分紅兩個部分,第一部分指定哈希值的生成算法(sha25六、sha384 及 sha512),第二部分是通過 base64 編碼的實際哈希值,二者之間經過一個短橫(-)分割。integrity 值能夠包含多個由空格分隔的哈希值,只要文件匹配其中任意一個哈希值,就能夠經過校驗並加載該資源。上述例子中我使用了 sha256 和 sha384 兩種 hash 方案。java

備註:crossorigin="anonymous" 的做用是引入跨域腳本,在 HTML5 中有一種方式能夠獲取到跨域腳本的錯誤信息,首先跨域腳本的服務器必須經過 Access-Controll-Allow-Origin 頭信息容許當前域名能夠獲取錯誤信息,而後是當前域名的 script 標籤也必須聲明支持跨域,也就是 crossorigin 屬性。link、img 等標籤均支持跨域腳本。若是上述兩個條件沒法知足的話, 可使用 try catch 方案。webpack

爲何要使用 SRI

在 Web 開發中,使用 CDN 資源能夠有效減小網絡請求時間,可是使用 CDN 資源也存在一個問題,CDN 資源存在於第三方服務器,在安全性上並不徹底可控。git

CDN 劫持是一種很是難以定位的問題,首先劫持者會利用某種算法或者隨機的方式進行劫持(狡猾大大滴),因此很是難以復現,不少用戶出現後刷新頁面就再也不出現了。以前公司有同事作遊戲的下載器就遇到這個問題,用戶下載遊戲後解壓不能玩,後面經過文件逐一對比找到緣由,原來是 CDN 劫持致使的。怎麼解決的呢?據說是找 xx 交了保護費,後面也是利用文件 hash 的方式,想必原理上也是跟 SRI 相同的。github

所幸的是,目前大多數的 CDN 劫持只是爲了作一些夾帶,好比經過 iframe 插入一些貼片廣告,若是劫持者別有用心,好比 xss 注入之類的,仍是很是危險的。web

開啓 SRI 能有效保證頁面引用資源的完整性,避免惡意代碼執行。算法

瀏覽器如何處理 SRI

  • 當瀏覽器在 script 或者 link 標籤中遇到 integrity 屬性以後,會在執行腳本或者應用樣式表以前對比所加載文件的哈希值和指望的哈希值。
  • 當腳本或者樣式表的哈希值和指望的不一致時,瀏覽器必須拒絕執行腳本或者應用樣式表,而且必須返回一個網絡錯誤說明得到腳本或樣式表失敗。

使用 SRI

經過使用 webpack 的 html-webpack-plugin 和 webpack-subresource-integrity 能夠生成包含 integrity 屬性 script 標籤。npm

import SriPlugin from 'webpack-subresource-integrity'
 
const compiler = webpack({
    output: {
        crossOriginLoading: 'anonymous',
    },
    plugins: [
        new SriPlugin({
            hashFuncNames: ['sha256', 'sha384'],
            enabled: process.env.NODE_ENV === 'production',
        })
    ]
})
複製代碼

那麼當 script 或者 link 資源 SRI 校驗失敗的時候應該怎麼作呢?

比較好的方式是經過 script 的 onerror 事件,當遇到 onerror 的時候從新 load 靜態文件服務器之間的資源:

<script type="text/javascript" src="//11.url.cn/aaa.js"
        integrity="sha256-xxx sha384-yyy"
        crossorigin="anonymous"
        onerror="loadScriptError.call(this, event)"
        onsuccess="loadScriptSuccess"></script>
複製代碼

在此以前注入如下代碼:

(function () {
	function loadScriptError (event) {
		// 上報
		...
		// 從新加載 js
		return new Promise(function (resolve, reject) {
			var script = document.createElement('script')
			script.src = this.src.replace(/\/\/11.src.cn/, 'https://x.y.z') // 替換 cdn 地址爲靜態文件服務器地址
			script.onload = resolve
			script.onerror = reject
			script.crossOrigin = 'anonymous'
			document.getElementsByTagName('head')[0].appendChild(script)
		})
	}
	function loadScriptSuccess () {
		// 上報
		...
	}
	window.loadScriptError = loadScriptError
	window.loadScriptSuccess = loadScriptSuccess
})()
複製代碼

比較痛苦的是 onerror 中的 event 中沒法區分到底是什麼緣由致使的錯誤,多是資源不存在,也多是 SRI 校驗失敗,固然出現最多的仍是請求超時,不過目前來看,除非有統計需求,無差異對待並無多大問題。

注入 onerror 事件

固然,因爲項目中的 script 標籤是由 webpack 打包進去的,因此咱們要使用 script-ext-html-webpack-plugin 將 onerror 事件和 onsuccess 事件注入進去:

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

module.exports = {
  //...
  plugins: [
    new HtmlWebpackPlugin(),
    new SriPlugin({
      hashFuncNames: ['sha256', 'sha384']
    }),
	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)'
	  }
	})
  ]
}
複製代碼

而後將 loadScriptError 和 loadScriptSuccess 兩個方法注入到 html 中,可使用 inline 的方式。

如何判斷髮生 CDN 劫持?

前面說到 script 加載失敗多是因爲多種緣由形成的,那如何是否判斷髮生了 CDN 劫持呢?

方法就是再請求一次數據,比較兩次獲得文件的內容(固然沒必要所有比較),若是內容不一致,就能夠得出結論了。

function loadScript (url) {
	return fetch(url).then(res => {
		if (res.ok) {
			return res
		}
		return Promise.reject(new Error())
	  }).then(res => {
	  	return res.text()
	}).catch(e => {
		return ''
	})
}
複製代碼

比較兩次加載的 script 是否相同

function checkScriptDiff (src, srcNew) {
	return Promise.all([loadScript(src), loadScript(srcNew)]).then(data => {
		var res1 = data[0].slice(0, 1000)
		var res2 = data[1].slice(0, 1000)
		if (!!res1 && !!res2 && res1 !== res2) {
			// CDN劫持事件發生
		}
	}).catch(e => {
		// ...
	})
}
複製代碼

這裏爲何只比較前 1000 個字符?由於一般 CDN 劫持者會在 js 文件最前面注入一些代碼來達到他們的目的,注入中間代碼須要 AST 解析,成本較高,因此比較所有字符串沒有意義。若是你仍是有顧慮的話,能夠加上後 n 個字符的比較。

最後

還在知乎上看到一位大神另闢蹊徑,經過相似 jsonp 的方式解決 CDN 劫持。我的感受這種方式目前可以完美應對 CDN 劫持的主要緣由是運營商經過文件名匹配的方式進行劫持,做者的方式就是經過 onerror 檢測攔截,而且去掉資源文件的 js 後綴以應對 CDN 劫持。

應對流量劫持,前端能作哪些工做?

這篇文章思路清晰,很是推薦學習。


《IVWEB 技術週刊》 震撼上線了,關注公衆號:IVWEB社區,每週定時推送優質文章。

相關文章
相關標籤/搜索