一种在SpirngMVC3中防御CSRF攻击的实现

关于CSRF是什么东西,请参见我的博文:《浅析CSRF》。本文将给出一种在 SpringMVC3+Velocity 的框架下防御CSRF攻击的解决方案。主要思想参考 EYAL LUPU[1] 的解决方案,但使用Velocity宏来进行Token值的传递,而不是使用spring form标签。

一、方案概要

在服务器端生成私有的会话级token,使用Velocity的宏命令在客户端的Form表单中插入这个token;在表单提交后对,服务器端对token值进行校验,根据结果来区分该次请求是否合法:正确就放行,不正确就就阻断请求。

在这里,我们约定:凡是更新资源的操作,都通过POST向服务器端发送。这也是符合HTTP规范的约定。

二、Token生成

使用一个 CSRFTokenManager 类来进行token生成的工作,代码如下:

CSRFTokenManager.javacode view
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
package com.javan.security;

import java.util.UUID;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;

/**
* A manager for the CSRF token for a given session. The {@link #getTokenForSession(HttpSession)} should used to obtain
* the token value for the current session (and this should be the only way to obtain the token value).
*/

public final class CSRFTokenManager {

/**
* The token parameter name
*/

static final String CSRF_PARAM_NAME = "xToken";

/**
* The location on the session which stores the token
*/

public final static String CSRF_TOKEN_FOR_SESSION_ATTR_NAME = CSRFTokenManager.class.getName() + ".tokenval";

public static String getTokenForSession(HttpSession session) {
String token = null;
// cannot allow more than one token on a session - in the case of two requests trying to
// init the token concurrently
synchronized (session) {
token = (String) session.getAttribute(CSRF_TOKEN_FOR_SESSION_ATTR_NAME);
if (null == token) {
token = UUID.randomUUID().toString();
session.setAttribute(CSRF_TOKEN_FOR_SESSION_ATTR_NAME, token);
}
}
return token;
}

/**
* Extracts the token value from the session
*
* @param request
* @return
*/

public static String getTokenFromRequest(HttpServletRequest request) {
String token = request.getParameter(CSRF_PARAM_NAME);
if (token == null || "".equals(token)) {
token = request.getHeader(CSRF_PARAM_NAME);
}
return token;
}

private CSRFTokenManager() {
};
}

getTokenForSession方法用于检查HTTP session中是否存在CSRF token:存在则返回;不存在则生成并存储到session中,然后返回。

三、Token依附到Form

由于我的前端使用 Velocity 进行渲染,并且不使用 Spring form标签,所以为了将token依附到Form表单中,我使用 Velocity 的宏指令进行渲染工作。

SpringMVC 中的配置,主要是配置 Velocity Tools

mvc-context.xmlcode view
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<bean id="velocityConfig"
class="org.springframework.web.servlet.view.velocity.VelocityConfigurer">

<property name="resourceLoaderPath" value="/WEB-INF/views/" />
<property name="velocityProperties">
<props>
<prop key="input.encoding">utf-8</prop>
<prop key="output.encoding">utf-8</prop>
</props>
</property>
<!-- Velocity的配置文件 -->
<property name="configLocation" value="/WEB-INF/velocity.properties" />
</bean>
<bean id="viewResolver"
class="org.springframework.web.servlet.view.velocity.VelocityViewResolver">

<property name="cache" value="true" />
<property name="contentType" value="text/html;charset=utf-8" />
<property name="exposeSpringMacroHelpers" value="true" />
<property name="suffix">
<value>.vm</value>
</property>
<!-- Velocity tools的配置文件 -->
<property name="toolboxConfigLocation" value="WEB-INF/toolbox.xml" />
<property name="exposeSessionAttributes" value="true" />
</bean>

Toolbox.xml中定义生成 CSRF token 的 tool:

toolbox.xmlcode view
1
2
3
4
5
6
7
<toolbox>
<tool>
<key>csrfTool</key>
<scope>application</scope>
<class>com.javan.util.CSRFTool</class>
</tool>
</toolbox>

工具类CSRFTool.java:

CSRFTool.xmlcode view
1
2
3
4
5
6
7
8
9
10
11
package com.javan.util;

import javax.servlet.http.HttpServletRequest;

import com.javan.security.CSRFTokenManager;

public class CSRFTool {
public static String getToken(HttpServletRequest request) {
return CSRFTokenManager.getTokenForSession(request.getSession());
}
}

页面渲染CSRF token的宏命令:

macros-default.vmcode view
1
2
3
4
5
6
7
8
9
#** CSRFToken
* Generate a input field of type 'hidden' of token to avoid CSRF attack
*#
#macro( CSRFToken $id)
#if(!$id || $id == "")
#set($id="xToken")
#end
<input type="hidden" id="$id" name="xToken" value="$csrfTool.getToken($request)"/>
#end

在前端的Form表单中添加token值:

1
2
3
4
<form id="userForm" action="/xxx.do" method="post">
#CSRFToken()
...
</form>

四、验证Token

当请求提交后,在服务器端验证token值,在这里定义一个拦截器CSRFHandlerInterceptor:

CSRFHandlerInterceptor.javacode view
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package com.javan.security;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import org.springframework.web.servlet.resource.DefaultServletHttpRequestHandler;

/**
* A Spring MVC <code>HandlerInterceptor</code> which is responsible to enforce CSRF token validity on incoming posts
* requests. The interceptor should be registered with Spring MVC servlet using the following syntax:
*
* <mvc:interceptors>
* <bean class="com.javan.security.CSRFHandlerInterceptor"/>
* </mvc:interceptors>
*
* @see CSRFRequestDataValueProcessor
*/

public class CSRFHandlerInterceptor extends HandlerInterceptorAdapter {

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

if (handler instanceof DefaultServletHttpRequestHandler) {
return true;
}

if (request.getMethod().equalsIgnoreCase("GET")) {
// GET - allow the request
return true;
} else {
// This is a POST request - need to check the CSRF token
String sessionToken = CSRFTokenManager.getTokenForSession(request.getSession());
String requestToken = CSRFTokenManager.getTokenFromRequest(request);
if (sessionToken.equals(requestToken)) {
return true;
} else {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Bad or missing CSRF value");
return false;
}
}
}
}

在bean配置文件中配置:

1
2
3
4
<!-- CSRF Interceptor handlers -->
<mvc:interceptors>
<bean class="com.javan.security.CSRFHandlerInterceptor" />
</mvc:interceptors>

五、注意事项

如果项目web.xml中指定了error-page错误页面(比如403状态码对应的页面等),那么需要注意CSRFHandlerInterceptor拦截的问题。

如果验证成功,请求将沿着处理链继续传递;如果验证失败,请求将被暂停,发出一个HTTP 403的状态代码作为响应。但是问题来了,如果设置了error-page,Servlet 容器会先根据响应状态码将原始请求转发(forward)到具体的错误页面,然后发送到客户端浏览器。由于使用了拦截器,这次forward请求会再次被拦截,验证方法也会被触发,会再次验证失败。前端看不到403的错误提示页面。具体解决方案参见这篇文章[2]

源码已放到Github上,有需要的童鞋请移步 这里

参考资料

[1] EYAL LUPU 的解决方案: http://blog.eyallupu.com/2012/04/csrf-defense-in-spring-mvc-31.html

[2] 再谈Spring MVC中对于CSRF攻击的防御:http://blog.csdn.net/alphafox/article/details/8947117