使用 SRI 解決 CDN 劫持

騰訊IVWEB團隊發表於2019-03-04

最近專案頻頻遇到 CDN 劫持的事情,學習到可以通過 Subresource Integrity 的方式有效應對。

SRI 簡介

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

通過給 link 標籤或者 script 標籤增加 integrity 屬性即可開啟 SRI 功能,比如:

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

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

備註:crossorigin="anonymous" 的作用是引入跨域指令碼,在 HTML5 中有一種方式可以獲取到跨域指令碼的錯誤資訊,首先跨域指令碼的伺服器必須通過 Access-Controll-Allow-Origin 頭資訊允許當前域名可以獲取錯誤資訊,然後是當前域名的 script 標籤也必須宣告支援跨域,也就是 crossorigin 屬性。link、img 等標籤均支援跨域指令碼。如果上述兩個條件無法滿足的話, 可以使用 try catch 方案。

為什麼要使用 SRI

在 Web 開發中,使用 CDN 資源可以有效減少網路請求時間,但是使用 CDN 資源也存在一個問題,CDN 資源存在於第三方伺服器,在安全性上並不完全可控。

CDN 劫持是一種非常難以定位的問題,首先劫持者會利用某種演算法或者隨機的方式進行劫持(狡猾大大滴),所以非常難以復現,很多使用者出現後重新整理頁面就不再出現了。之前公司有同事做遊戲的下載器就遇到這個問題,使用者下載遊戲後解壓不能玩,後面通過檔案逐一對比找到原因,原來是 CDN 劫持導致的。怎麼解決的呢?聽說是找 xx 交了保護費,後面也是利用檔案 hash 的方式,想必原理上也是跟 SRI 相同的。

所幸的是,目前大多數的 CDN 劫持只是為了做一些夾帶,比如通過 iframe 插入一些貼片廣告,如果劫持者別有用心,比如 xss 注入之類的,還是非常危險的。

開啟 SRI 能有效保證頁面引用資源的完整性,避免惡意程式碼執行。

瀏覽器如何處理 SRI

  • 當瀏覽器在 script 或者 link 標籤中遇到 integrity 屬性之後,會在執行指令碼或者應用樣式表之前對比所載入檔案的雜湊值和期望的雜湊值。
  • 當指令碼或者樣式表的雜湊值和期望的不一致時,瀏覽器必須拒絕執行指令碼或者應用樣式表,並且必須返回一個網路錯誤說明獲得指令碼或樣式表失敗。

使用 SRI

通過使用 webpack 的 html-webpack-plugin 和 webpack-subresource-integrity 可以生成包含 integrity 屬性 script 標籤。

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社群,每週定時推送優質文章。

相關文章