設計目標
Valine
Valine 是一款樣式精美、操作簡單、部署高效的留言系統。第一次接觸便被它精美的樣式、無伺服器的特性給吸引。它不含伺服器,前端直接與 LeanCloud 儲存服務互動,真是太酷了!但隨著深入了解,我發現它存在一些問題。
Valine 的問題
原始碼不開放
作者不知為何從 1.4.0 版本開始只推送編譯後的檔案到 GitHub 儲存庫中,原始碼檔案停止更新。可能是被開源傷了心吧。對於我這種想增加或者修改功能的使用者來說,這個問題就有點難受了。
XSS 安全
從很早的版本開始就有使用者回報了 Valine 的 XSS 問題,社群也在使用各種方法修復這些問題。包括增加驗證碼、前端 XSS 過濾等方式。不過後來作者才明白,前端的一切驗證都只能防君子,所以又去除了驗證碼之類的限制。
現有的邏輯裡,前端發佈留言的時候會將 Markdown 轉換成 HTML,然後走一下前端的一個 XSS 過濾方法,最後提交到 LeanCloud 中。從 LeanCloud 中拿到資料之後,因為是 HTML,直接插入進行顯示即可。很明顯,這個流程是存在問題的。只要直接提交的是 HTML,而且拿到 HTML 之後直接進行展示的話,XSS 從根本上是無法根除的。
根本的解決辦法
針對儲存型的 XSS 攻擊,我們可以使用 HTML 跳脫編碼一勞永逸的解決。只需效仿早前 BBCode 的做法,提交到資料庫的是 Markdown 內容。前端讀取到內容,對所有 HTML 進行編碼後再進行 Markdown 轉換後展示。
function encodeForHTML(str) {
return ('' + str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/\//g, '/');
}由於 Serverless 攻擊者是可以直達儲存階段,所以資料儲存之前的一切防範是無效的,只能在讀取展示過程處理。由於所有的 HTML 跳脫後無法解析,Markdown 相當於我們根據自訂的語法解析成 HTML,保證轉換後的 HTML 沒有被插入的機會。由於 Valine 沒有開放原始碼,所以無法進行 Pull Request。
由於上述辦法會完全將使用者限制在 Markdown 的範圍內,目前 Waline 在客戶端自 0.15.0 後引入了 DOMPurify 來阻止 XSS。除常規的 XSS 過濾外,針對性的:
- 停用
<form>、<input>等表單控制項 - 停用使用者樣式的加入
- 停用自動拖曳屬性與媒體的自動播放
- 所有的外部連結均會被處理,以
noopener noreferrer的形式在新視窗開啟
隱私洩露
攻擊者除了可以任意儲存,也可以任意讀取,資料庫的欄位開放讀取權限後,該欄位的內容對攻擊者是完全透明的。
在留言資料中,IP 和電子郵件兩個欄位包含了使用者的敏感資料。燈大甚至專門寫了一篇文章來批判該問題 《請馬上停止使用 Valine.js 留言系統,除非它修復了使用者隱私洩露問題》。甚至掘金社群在早期使用 LeanCloud 的時候也爆出過 洩露使用者手機號 的安全問題。
為了規避這個問題,Valine 作者增加了 recordIP 設定用來設定是否允許記錄使用者 IP。由於無伺服器,只能透過不儲存的方式解決。
該設定項仍存在一個問題: 記錄設定權在網站,留言者無權管理自己的隱私。
電子郵件洩露是另一個重大隱私問題。在前端計算並上報使用者電子郵件的 md5 用來取得 Gravatar 頭像是完全可行的。但是如果需要在留言被回覆時發送郵件通知,就不可避免的要儲存使用者電子郵件的原始值。這個問題理論上可以透過 RSA 加密來解決,私鑰儲存在 LeanCloud 的環境變數中,客戶端同時上報電子郵件 md5 和公鑰加密後的電子郵件,LeanCloud 在發送郵件通知時在雲端函式中透過環境中的私鑰解密取得使用者電子郵件。但是考慮到前端 RSA 加密函式庫的體積與效能,實際應用可行性很小。增加一層伺服器,透過伺服器過濾敏感資訊是一個較優的做法。
閱讀統計竄改
Valien 1.2.0 增加了文章閱讀統計的功能,使用者造訪頁面就會在後台 Counter 表中根據 url 記錄造訪次數。由於每次造訪頁面都需要更新資料,所以在權限上必須設定成可寫,才能進行後續的欄位更新。這樣就造成了一個問題,實際上該條資料是可以被更新成任意值的。感興趣的同學可以開啟 https://valine.js.org/visitor.html 官網頁面後進入主控台輸入以下程式碼試試。試完了記得把數字改回去哈~
const counter = new AV.Query('Counter');
const resp = await counter.equalTo('url', '/visitor.html').find();
resp[0].set('time', -100000).save();
location.reload();值得慶幸的是 time 欄位的值是 Number 型別的,這樣其它的值無法插入。如果 time 欄位是字串型別會產生新的 XSS 漏洞。該問題有一個可行解決辦法,就是不使用次數累加的儲存方式。更改為每次造訪都儲存一條唯讀的造訪記錄,讀取的時候使用 count() 方法進行統計。這樣所有資料都是唯讀的,解決了竄改的問題。這種解決方案也存在問題: 資料量比較大時對查詢會造成一定壓力。
如果需要保持原資料結構,只能增加一層伺服器進行修改權限的隔離。
Waline 的誕生
基於以上原因,Waline 橫空出世了。Waline 最初的目標僅僅是為 Valine 增加上伺服器中間層,但是由於 Valine 的不開源所以只能連帶前端也實現一遍。當然前端的很多程式碼和邏輯為了和 Valine 的設定保持一致都有參考 Valine。甚至在名字上,我也是從 Valine 上衍生的,讓大家能明白這個專案是 Valine 的衍生版。
增加了伺服器除了解決上述的安全問題,之前 Valine 受限於無伺服器的很多功能也可以實現了。包括但不限於郵件通知、垃圾留言過濾等。
