最近項目頻頻遇到 CDN 劫持的事情,學習到能夠經過 Subresource Integrity 的方式有效應對。javascript
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
在 Web 開發中,使用 CDN 資源能夠有效減小網絡請求時間,可是使用 CDN 資源也存在一個問題,CDN 資源存在於第三方服務器,在安全性上並不徹底可控。git
CDN 劫持是一種很是難以定位的問題,首先劫持者會利用某種算法或者隨機的方式進行劫持(狡猾大大滴),因此很是難以復現,不少用戶出現後刷新頁面就再也不出現了。以前公司有同事作遊戲的下載器就遇到這個問題,用戶下載遊戲後解壓不能玩,後面經過文件逐一對比找到緣由,原來是 CDN 劫持致使的。怎麼解決的呢?據說是找 xx 交了保護費,後面也是利用文件 hash 的方式,想必原理上也是跟 SRI 相同的。github
所幸的是,目前大多數的 CDN 劫持只是爲了作一些夾帶,好比經過 iframe 插入一些貼片廣告,若是劫持者別有用心,好比 xss 注入之類的,仍是很是危險的。web
開啓 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 校驗失敗,固然出現最多的仍是請求超時,不過目前來看,除非有統計需求,無差異對待並無多大問題。
固然,因爲項目中的 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 的方式。
前面說到 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社區,每週定時推送優質文章。