CSRF是什么,就不多說,網絡上的帖子多的去了,關於其定義。
這里主要介紹我們項目中,是如何解決這個問題的。方案比較簡單,重點是介紹和記錄一下遇到的問題和一些小的心得。
1. 解決方案
A. 用戶登錄的時候,將創建一個token,此token存放於session當中。(是否在登錄后創建token,依據各自系統需求變化)
B. 基於Filter,對所有的Http請求進行攔截,捕獲請求路徑,確認路徑URL是否在配置的CSRF安全攔截路徑列表CsrfList中。
C. 若在CsrfList中,則檢查session中是否含有sToken字段以及Http請求頭中是否含有rToken字段。
D. 若sToken和rToken相等,則認為安全合法的請求,否則將請求攔截,拒絕此次請求。
這里:
1》. 主要是基於過濾器Filter來實現,另外,一個比較核心的思想,是將安全路徑(需要校驗的,比如系統參數相關的增刪改相關的數據提交請求)通過配置的方式,以配置文件或者數據庫表的形式配置在系統中(本案例,采用的是靜態配置文件)。說白了,和Shiro或者Spring security的權限管理很像。
2》. 另外一點,將后台生成的token數據傳遞前端,並在前端有數據提交的時候將這個token值帶回到后台。笨一點的辦法,就是在每次ajax數據提交的時候,都給調用beforeSend方法給XMLHttpRequest里面添加自定義的Header屬性(當然,也可以通過其他方式實現token的回傳到后台,我這里采用的是Http的自定義Header屬性的模式)。最好是有一個全局的配置,至少是文件級別的配置,減少ajax提交數據的時候寫入重復的beforeSend調用。
2. 核心代碼
核心代碼,分Filter后端的部分,以及前端的beforeSend調用部分。
1》. Filter對應的后端部分
package com.tk.logc.core.csrf; import java.io.IOException; import java.io.InputStream; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.Map.Entry; import java.util.Properties; import java.util.Set; import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.FilterConfig; import javax.servlet.ServletException; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; import org.apache.log4j.Logger; import org.apache.shiro.SecurityUtils; import org.apache.shiro.session.Session; import org.apache.shiro.subject.Subject; import com.google.gson.Gson; import com.tk.logc.core.Constants; import com.tk.logc.core.ResultData; /** * @author shihuc * @date 2018年8月21日 上午9:23:02 */ public class CsrfFilter implements Filter{ static Logger logger = Logger.getLogger(CsrfFilter.class); static Set<String> csrfUrls = new HashSet<String>(); static { InputStream in = CsrfFilter.class.getResourceAsStream("/conf/csrf.properties"); Properties properties = new Properties(); try { properties.load(in); } catch (IOException e) { e.printStackTrace(); } Iterator<Entry<Object, Object>> it = properties.entrySet().iterator(); while (it.hasNext()) { Entry<Object, Object> entry = it.next(); Object key = entry.getKey(); String keys = key.toString().trim(); String urlPref[] = keys.split("_"); String urlPrefix = "/"; for(String pu: urlPref){ urlPrefix += pu + "/"; } logger.info("Prefix: " + urlPrefix); Object value = entry.getValue(); String urls = value.toString(); String urlSuffix[] = urls.split(","); String realUrl = ""; for(String suffix: urlSuffix){ suffix = suffix.trim(); realUrl = urlPrefix + suffix; csrfUrls.add(realUrl); logger.info("URL: " + realUrl); } } } /* (non-Javadoc) * @see javax.servlet.Filter#destroy() */ @Override public void destroy() { // TODO Auto-generated method stub } /* (non-Javadoc) * @see javax.servlet.Filter#doFilter(javax.servlet.ServletRequest, javax.servlet.ServletResponse, javax.servlet.FilterChain) */ @Override public void doFilter(ServletRequest req, ServletResponse rsp, FilterChain chain) throws IOException, ServletException { HttpServletRequest hReq = (HttpServletRequest) req; Subject subject = SecurityUtils.getSubject(); boolean isAuthed = subject.isAuthenticated(); Session session = subject.getSession(); if(isAuthed) { Object csrfToken = session.getAttribute(Constants.CURRENT_USER_JOB_KEY); Object httpToken = hReq.getHeader(Constants.CURRENT_USER_JOB_KEY); String uri = hReq.getRequestURI().toString(); String ctx = hReq.getContextPath().toString(); String tarUri = uri.substring(ctx.length(), uri.length()); logger.info("REQ URL:" + tarUri + ", sToken: " + csrfToken + ", rToken: " + httpToken); if(csrfUrls.contains(tarUri)){ if (csrfToken != null && !csrfToken.equals(httpToken)){ Gson gson = new Gson(); ResultData<HashMap<String, String>> rd = new ResultData<HashMap<String, String>>(); rd.setSuccess(false); rd.setMsg(Constants.CURRENT_CSRF_ERRINFO); rsp.setCharacterEncoding("UTF-8"); rsp.setContentType("text/html;charset=UTF-8"); rsp.getWriter().write(gson.toJson(rd)); }else{ chain.doFilter(req, rsp); } }else{ chain.doFilter(req, rsp); } }else{ chain.doFilter(req, rsp); } } /* (non-Javadoc) * @see javax.servlet.Filter#init(javax.servlet.FilterConfig) */ @Override public void init(FilterConfig arg0) throws ServletException { // TODO Auto-generated method stub } }
這里,配置文件在靜態塊里面加載,這里采用了一點點小技巧,方便配置簡單化,因為一個后台系統配置功能頁面,往往會有多個操作,例如:create,update,delete等,配置的時候,可以將key和value部分優化,然后后台加載時,進行URL路徑組裝重配。例如,我這里的配置文件:
# #所有需要做CSRF攔截校驗的URL,沒有弄明白操作邏輯前,請勿修改 #Key部分是url組成的一部分,依據下划線分隔,和Value部分逗號分開的部分組合成最終的URL #Value部分,反映的是一類業務中多個子類型的操作,每個都用逗號分隔 #例如:a_b=u1,u2 對應的URL信息解析后是: /a/b/u1和/a/b/u2 # system_role=create,update,initUpdate,delete,saveRolePermission,addRolePermission system_user=deleteUser,createUser,updateUser,userRole,saveUserRole
2》. 前端JS的核心代碼
(function($){ var _ajax = $.ajax; $.ajax = function(options){ var fn = { beforeSend: function (XMLHttpRequest) { XMLHttpRequest.setRequestHeader("X-Job-Key", $("#csrfToken").val()); } }; if(options.beforeSend){ fn.beforeSend = options.beforeSend; } var _options = $.extend(options,{ beforeSend: fn.beforeSend }) _ajax(_options); } })(jQuery);
這段JS代碼,是前端的核心,擴展了jQuery的ajax的行為,主要是將beforeSend函數擴展了,在每次只需ajax的時候,都要執行beforeSend,完成給Http請求頭部添加一個自定義的屬性值,供后台收到請求的時候,解析校驗。
3. 注意事項
這里,主要涉及到幾點,都是一些細節,容易落入坑里:
1》.前端用http頭部自定義的屬性,比較用Cookie安全,為了高效,通過擴展ajax的請求,就像我上面的核心代碼JS部分的例子一樣,每一個JS文件里面,類似上面加入這段代碼。注意:代碼最后有一個分號,這個分號一定得加上,否則,在一個JS文件里面,若有多個(function($){})(jQuery)這樣的代碼段,就會出現下面的錯誤。
Uncaught TypeError: (intermediate value)(intermediate value)(...) is not a function at VM71 xxxx.js:22
為了效率,beforeSend的使用,若只有少量的地方使用,可以采用下面的模式,在需要的ajax調用里面使用。
$.ajax({ url: url, data: {"id":roleId}, dataType:"json", //stype:"GET", beforeSend: function (XMLHttpRequest) { XMLHttpRequest.setRequestHeader("X-Job-Key", $("#randomx").val()); }, success: function(data){ if(data.isSuccess){ //初始化數據 initRoleData(data.object); }else{ bootbox.alert(data.msg); } } });
2》.Http頭部定義的屬性變量,不建議使用帶有下划線的變量。
這里,之所以這么說,主要是因為現在的web應用系統,很多會采用Nginx作為反向代理,Nginx會對Http請求頭部的帶有下划線的屬性進行過濾處理,丟棄掉了。這樣一來,帶有下划線的屬性,ajax或者其他模式發起的HTTP請求,就會被Nginx默認給丟棄了,后台應用服務器上,就獲取不到該變量。
下面是Nginx官方文檔對變量定義中下划線的描述:
underscores_in_headers
Context: http, server
Allows or disallows underscores in custom HTTP header names. If this directive is set to on, the following example header is considered valid by Nginx: test_ header: value.
Syntax: on or off Default value: off
這個問題,被坑的現象是,在本地調試一點問題沒有,上測試環境,就總是失敗,獲取rToken值總是null,才想起有這么一個坑。。。
