SHIHUC

好记性不如烂笔头,还可以分享给别人看看! 专注基础算法,互联网架构,人工智能领域的技术实现和应用。
  博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

CSRF的防御解决过程

Posted on 2018-08-28 14:11  shihuc  阅读(7101)  评论(1编辑  收藏  举报

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,才想起有这么一个坑。。。