最近項目頻頻遇到 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社區,每週定時推送優質文章。