XSS與CSRF
哈哈,有點標題黨,但我保證這篇文章跟別的不太一樣。
我認為,網站安全的基礎有三塊:
- 防范中間人攻擊
- 防范XSS
- 防范CSRF
注意,我講的是基礎,如果更高級點的話可以考慮防范機器人刷單,再高級點就防范DDoS攻擊,不過我們還是回到“基礎”這個話題上吧,對於中間人攻擊,使用HTTPS是正確且唯一的做法,其它都是歪門邪道,最好還要購買各個瀏覽器都承認的SSL證書;防范XSS,關鍵點在於將用戶提交數據呈現在頁面上的時候,需要使用Html Encode,或在處理帶HTML格式的用戶表單數據時,進行“消毒”(Sanitize)處理,關於這個,我前一篇文章《讓ASP.NET接受有“潛在危險”的提交》已經講述了應該怎么做;剩下了這個CSRF是本文要講的,我認為防范CSRF的前提是必須先做好XSS的防范工作,因為:CSRF防的是別的網站,如果自己的網站本身有XSS漏洞,被別人注入了有害腳本,那么就變成了“家賊難防”了。
至於什么是CSRF,如何讓ASP.NET防范CSRF,這種文章很多的,比如博客園里這篇就可以:《ASP.NET MVC 防止 CSRF 的方法》,我總結一下一般的做法,也就這兩點:
- 在cshtml頁面的form標簽里加上@Html.AntiForgeryToken()
- 在Controller需要防范CSRF的Action上加上[ValidateAntiForgeryToken]注解
Done!
原理
為啥這樣就行了呢?我簡單說說原理:@Html.AntiForgeryToken()的作用是在頁面上插入一個type為hidden的input標簽,它的name固定是__RequestVerificationToken,value則是一長串密文,這是真的密文,是經過加密的,那明文是什么?是隨機生成的128位數字,跟GUID差不多的東西,我們叫它“隨機明文”吧,再附帶一點額外的信息(忽略這個吧),然后加密,加密器是這個玩意兒:System.Web.Security.MachineKey,我這里寫個簡單的Example,大家可以弄個HelloWorld程序看看運行效果。
byte[] plainText = Encoding.UTF8.GetBytes("123456"); string[] purposes = { "blah blah blah" }; byte[] cypherText = MachineKey.Protect(plainText, purposes); Console.WriteLine(Convert.ToBase64String(cypherText)); plainText = MachineKey.Unprotect(cypherText, purposes); Console.WriteLine(Encoding.UTF8.GetString(plainText));
多運行幾次,每次都能解出正確的明文,但是密文每次都不一樣,所以大家在不斷刷新頁面的時候,發現每次生成的value也不一樣,但沒事,它們的“隨機明文”是一樣的。除了生成這個input標簽之外,@Html.AntiForgeryToken()還做了個額外的動作,那就是生成一個同樣名字(也叫__RequestVerificationToken)的Cookie,內容差不多,在我們看來也是一長串密文。所以總結回來@Html.AntiForgeryToken()就做了這兩件事:
- 頁面上加上一個像這樣的標簽<input name="__RequestVerificationToken" type="hidden" value="(一長串密文)" />
- 生成一個名為__RequestVerificationToken的Cookie,值為“一長串密文”
接下來輪到ValidateAntiForgeryToken過濾器,它收到了請求,就嘗試解出請求中的Cookie的“一長串密文”和請求中的Form的“一長串密文”,解密后比對兩者的“隨機明文”,如果一致,則通過,(其實作為高級用法你還可以自定義一些額外的規則,不過這不在本文講述范圍內)否則拋出HttpAntiForgeryException異常。
為什么只需要檢驗下Cookie和Form的“隨機明文”就可以防范CSRF了呢?其實理解起來並不難,前面說了CSRF防的是別的網站,別的網站偽造了請求,利用訪問者的瀏覽器對目標網站發送了這個請求,但偽造者並不清楚目標網站的訪問者的__RequestVerificationToken這個Cookie的值,因此表單中的__RequestVerificationToken的值也就無法偽造,這個請求會被ValidateAntiForgeryToken過濾器攔截下來。
特點與局限性
知道了原理,就來分析下它的特點與局限性,首先很容易想到的就是:
- 瀏覽器必須要支持Cookie
- 只能驗證POST請求(因為需要Form)
另外思考一下如果一個頁面中調用了多次@Html.AntiForgeryToken(),生成了多個input標簽,會怎樣呢?會不會生成了兩個不同的Token,最后比對出錯?其實不必擔心,正兒八經的那個隨機明文只會生成一次,Html.AntiForgeryToken()方法會檢查你提交的Cookie,如果已存在__RequestVerificationToken,那么它就不會再生成一個新的隨機數明文了。否則如果每次都生成一個隨機數明文,你的頁面上如果有兩個Form的話,其中一個肯定沒法正常提交,更不用說AJAX提交的情況。
AJAX POST
如果不是直接提交頁面上的表單,而是AJAX POST,像這樣:
$.ajax({ type: "post", url: "/testurl", data: {test:'abc'}, success: function (data) { //done! } });
這可咋辦?你必須想方設法在data中帶上正確的__RequestVerificationToken啊!StackOverflow上有個解決方案,挺不錯,大家參考下:Go to StackOverflow,簡單地說就是寫一個ASP.NET MVC的HTML生成幫助方法,用於生成那“一長串密文”,交給這個ajax的data。
但這可不是我想說的“最佳實踐” ,再考慮一種情況:用js動態生成Form,然后Submit。嗯,我承認這個有點奇葩,但在我的項目中確實有不少地方是這么干的,你別問為什么了,反正就是有,遇到這種情況,咋辦?看來還是得求助於js。下面我分享下我的做法:
我的“最佳實踐”
首先我沒有把@Html.AntiForgeryToken()放到每一個Form中,我只在一處地方用到了@Html.AntiForgeryToken(),那就是母版頁!接下來把下面這段js放到common.js中(common.js是母版頁引用的js,也就是說每個頁面會引用到):
//處理form的submit事件,添加AntiForgeryToken到表單里 $("body").on("submit", "form", function () { var theForm = $(this); if (theForm.find("input[name='__RequestVerificationToken']").length === 0) { var antiForgery = $("input[name='__RequestVerificationToken']:first").val(); if (antiForgery) { var theAntiForgeryTokenInput = $('<input />').attr('type', 'hidden') .attr('name', '__RequestVerificationToken') .attr('value', antiForgery); $(this).prepend(theAntiForgeryTokenInput); } } });
這樣一來,所有的form的submit動作就會在這里被處理一下,添加上了__RequestVerificationToken這個字段。接下來是AJAX POST的處理:
data= {test:'abc'}; var antiForgery = $("input[name='__RequestVerificationToken']:first").val(); if (antiForgery) { if (!data.__RequestVerificationToken) { data.__RequestVerificationToken = antiForgery; } } $.ajax({ type: "post", url: "/testurl", data: data, success: function (data) { //done! } });
嗯?你也許要問,每個用到AJAX POST的地方都加上這么一段代碼豈不是很繁瑣?是的,但在我的項目中,我用了幾個公共的方法對AJAX POST進行了一些封裝,所以只需要改好這幾個地方即可,你可以根據自己的項目的實際情況進行優化處理。
還有一種情況是用AJAX來提交Form,而不是像上面這樣的data:
var form = $("#the-form-id"); var dataToSubmit = form.serializeArray(); var antiForgery = $("input[name='__RequestVerificationToken']:first").val(); if (antiForgery) { var found = false; for (var i = 0; i < dataToSubmit.length; i++) { if (dataToSubmit[i].name === '__RequestVerificationToken') { found = true; break; } } if (!found) { dataToSubmit.push({ name: "__RequestVerificationToken", value: antiForgery }); } } $.ajax({ type: method, url: urlToSubmit, data: dataToSubmit, success: function (data) { //done! } });
照舊,根據你的項目的實際情況封裝一下,其實真正要改動的地方不多,只要你框架搭好了。
總結一下,框架搭好了的前提下,為了防范CSRF,你所需要做的事情就僅剩下:給帶[HttpPost]注解的Action添加[ValidateAntiForgeryToken]。
至於驗證失敗拋出HttpAntiForgeryException異常導致默認錯誤頁面(我又叫它“死黃頁”,該死的黃頁的意思)出現的問題,你可以在Application_Error中處理一下啊,Google關鍵字“Application_Error”,一搜一大堆,或者,等我有空了再寫一篇這個主題的“最佳實踐”?