关于Form表单Toekn防止重复提交的一点实践

一、 前言

 作为一个信息安全专业的学生,又是一个Web应用开发者,在做Web应用的开发时,不得不对一些Web攻击防护手段进行一些学习与实践,这篇博客是对我进行Form表单设置Token值防止重复提交的学习过程的记录,分别为:1、自定义Tag标签设置Token值;2、自定义Token注解,设置Token拦截;3、前端Javascript防止表单重复提交。

二、 关于

2.1 表单重复提交的场景

 表单重复提交是在多用户Web应用中最常见、带来很多麻烦的一个问题。有很多的应用场景都会遇到重复提交问题,比如:

(1)点击提交按钮两次.

(2)点击刷新按钮。

(3)使用浏览器后退按钮重复之前的操作,导致重复提交表单。

(4)使用浏览器历史记录重复提交表单。

(5)浏览器重复的HTTP请求。

2.2 表单重复提交的危害

 简单看个例子:
TechWeb投票表单可重复提交刷票

 该例子就是利用表单重复提交造成恶意刷票。具体详细解读可见该链接,不在赘述。

 同样,表单的重复提交还可能造成应用存在CSRF的漏洞。

CSRF为什么能够攻击成功?其本质是重要操作的所有参数都是可以被攻击者猜测到。--------《白帽子讲Web安全》

 设想一下无论表单是GET请求或是POST请求,只要攻击者预测出URL所有参数及参数值,便能利用其发起攻击(例如2008年9月百度的CSRF Worm漏洞,有兴趣的朋友可以搜索了解)

以下简略引用《白帽子讲Web安全》中例子P109~P111:

删除搜狐博客URL:http://blog.souhu.com/manage/entry.do?=m=delete&id=13413241

攻击者自己构造一个页面:http://www.a.com/csrf.html

其内容为:

1
<img src="http://blog.souhu.com/manage/entry.do?=m=delete&id=13413241" />

其地址指向删除博客URl

 攻击者诱使用户访问http://www.a.com/csrf.html 这个页面,图片标签想搜狐服务器发送了一次GET请求,而此次请求删除了该用户的文章。
该例子之所以能够被搜狐服务器验证通过,是因为利用了用户的浏览器成功发送了Cookie。

TechWeb投票表单可重复提交刷票这个例子,也是利用构造POST请求进行操作。

2.3 表单重复提交的规避

2.3.1 禁掉提交按钮

 表单提交后使用Javascript使提交按钮disable。这种方法防止心急的用户多次点击按钮。但有个问题,如果客户端把Javascript给禁止掉,这种方法就无效了。

2.3.2 Post/Redirect/Get模式

 在提交后执行页面重定向,这就是所谓的Post-Redirect-Get (PRG)模式。简言之,当用户提交了表单后,你去执行一个客户端的重定向,转到提交成功信息页面。

 这能避免用户按F5导致的重复提交,而其也不会出现浏览器表单重复提交的警告,也能消除按浏览器前进和后退按导致的同样问题。

2.3.3 session中存放特殊标志

 当表单页面被请求时,生成一个特殊的字符标志串,存在session中,同时放在表单的隐藏域里。接受处理表单数据时,检查标识字串是否存在,并立即从session中删除它,然后正常处理数据。

 如果发现表单提交里没有有效的标志串,这说明表单已经被提交过了,忽略这次提交。

 这使你的web应用有了更高级的XSRF保护。

 但session中存放标志可能会引发问题,后文将会提及。

2.3.4 在数据库里添加约束

 在数据库里添加唯一约束或创建唯一索引,防止出现重复数据。这是最有效的防止重复提交数据的方法。

三、 自定义Tag标签方式

开发框架:SpringMVC4.x
开发环境:Mac OS X
开发工具:Intelli IDEA 15

3.1 自定义Tag标签

3.1.1 配置

 在 WEB-INF/下配置token.tld(路径自定):

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
<?xml version="1.0" encoding="ISO-8859-1"?>

<taglib xmlns="http://java.sun.com/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-jsptaglibrary_2_1.xsd"
version="2.1">


<tlib-version>1.0</tlib-version>
<short-name>myshortname</short-name>
<uri>http://mycompany.com</uri>

<!-- Invoke 'Generate' action to add tags or functions -->
<tag>
<!-- 标签名 -->
<name>token</name>
<!-- 标签类路径 -->
<tag-class>com.hgx.SpringMVCDemo.tag.TokenTag</tag-class>
<body-content>empty</body-content>
<attribute>
<name>token</name>
<required>false</required>
</attribute>
</tag>

</taglib>

3.1.2 TokenTag类

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
package com.hgx.SpringMVCDemo.tag;


import org.springframework.web.servlet.tags.RequestContextAwareTag;

import javax.servlet.jsp.JspException;
import java.util.UUID;

/**
* Created by huangguoxin on 16/2/11.
*/

public class TokenTag extends RequestContextAwareTag {
private static final long serialVersionUID = 4140002821890713194L;
private String token = null;

public String getToken() {
return token;
}

public void setToken(String token) {
this.token = token;
}

@Override
protected int doStartTagInternal() throws Exception {
String token = UUID.randomUUID().toString();
pageContext.getSession().setAttribute("token",
token);
setToken(token);
//<input type="hidden" name="token" value="UUID"/>
StringBuffer sb = new StringBuffer();
sb.append("<input");
sb.append(" type=\"" + "hidden" + "\"");
sb.append(" name=\"" + "token" + "\"");
sb.append(" value=\"" + token + "\"");
sb.append(" />");
pageContext.getOut().println(sb.toString());
return SKIP_BODY;
}

@Override
public int doEndTag() throws JspException {
return EVAL_PAGE;
}
}

3.1.2 web.xml配置

1
2
3
4
5
6
<jsp-config>
<taglib>
<taglib-uri>/TokenTag</taglib-uri>
<taglib-location>/WEB-INF/taglib/token.tld</taglib-location>
</taglib>
</jsp-config>

3.2 View层应用

edittag.jsp

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
<%@ page language="java" 
contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>

<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<!--引用标签-->
<%@ taglib prefix="token" uri="/TokenTag" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Test</title>

</head>
<body>
<div id="main">
<div class="newcontainer" id="course_intro">
<form name="mainForm" action="<%= request.getContextPath()%>/welcome/saveTag" method="post">
<!--使用标签-->
<token:token/>
<div>
<span>名称:</span><input type="text" id="title" name="title">
</div>

<div>
<span>简介:</span>
<textarea id="desc" name="desc" rows="5" style="width:480px"></textarea>

</div>
<div>
<input type="submit" id="btnPass" value="提交" />
</div>
</form>
</div>
</div>
</body>
</html>

3.3 Controller层示例代码

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
@RequestMapping(value = "/editTag",method = RequestMethod.GET)
public String editTagForm(){
return "edittag";
}

@RequestMapping(value = "/saveTag",method = RequestMethod.POST)
public String saveTag(@ModelAttribute TestModel testModel,HttpServletRequest request){
logger.info("Info getForm Title:{}",testModel.getTitle());
logger.info("Info getForm Desc:{}",testModel.getDesc());

logger.info("isRepeatSubmit:{}",isRepeatSubmit(request));
logger.info("Server Token Code:{}",request.getSession(false).getAttribute("token"));

if(isRepeatSubmit(request)){
logger.info("Repeat Submit:{}");
return "edittag";
}else{
request.getSession().removeAttribute("token");
return "redirect:hello3?id="+ testModel.getTitle();
}
}

//判断Token
private boolean isRepeatSubmit(HttpServletRequest request){
String serverToken = (String) request.getSession(false).getAttribute("token");
if (serverToken == null) {
return true;
}
String clientToken = request.getParameter("token");
if (clientToken == null) {
return true;
}
if (!serverToken.equals(clientToken)) {
return true;
}
return false;
}

四、自定义注解及设置拦截器

4.1 自定义Token注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.hgx.SpringMVCDemo.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* Created by huangguoxin on 16/2/10.
*/


@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Token {
boolean save() default false;
boolean remove() default false;
}

4.2 设置拦截器

4.2.1 拦截器实现

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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
package com.hgx.SpringMVCDemo.interceptor;

import com.hgx.SpringMVCDemo.annotation.Token;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
import java.util.UUID;

/**
* Created by huangguoxin on 16/2/10.
*/

public class TokenInterceptor extends HandlerInterceptorAdapter {

private static Logger logger = LoggerFactory.getLogger(TokenInterceptor.class);

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

if (handler instanceof HandlerMethod) {

logger.info("Into Interceptor:{}");
logger.info("isRepeatSubmit:{}",isRepeatSubmit(request));
logger.info("Server Token Code:{}",request.getSession(false).getAttribute("token"));
logger.info("Client Token Code:{}",request.getParameter("token"));
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
Token annotation = method.getAnnotation(Token.class);
if (annotation != null) {
boolean needSaveSession = annotation.save();
if (needSaveSession) {
request.getSession(false).setAttribute("token", UUID.randomUUID().toString());
}
boolean needRemoveSession = annotation.remove();
if (needRemoveSession) {
if (isRepeatSubmit(request)) {
logger.info("RepeatSubmit Form:{}");
return false;
}



request.getSession(false).removeAttribute("token");
}
}
return true;
} else {
return super.preHandle(request, response, handler);
}
}

//判断token是否重复
private boolean isRepeatSubmit(HttpServletRequest request) {
String serverToken = (String) request.getSession(false).getAttribute("token");
if (serverToken == null) {
return true;
}
String clientToken = request.getParameter("token");
if (clientToken == null) {
return true;
}
if (!serverToken.equals(clientToken)) {
return true;
}
return false;
}
}

4.2.2 拦截器的配置

在你的xxx-servlet.xml中配置

1
2
3
4
5
6
7
<!--注册拦截器-->
<mvc:interceptors>
<mvc:interceptor>
<mvc:mapping path="/welcome/edit"/>
<bean class="com.hgx.SpringMVCDemo.interceptor.TokenInterceptor" />
</mvc:interceptor>
</mvc:interceptors>

4.3 使用注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@RequestMapping(value = "/edit",method = RequestMethod.GET,params = "add")
@Token(save = true)
public String editForm(){
return "edit";
}


/**
* 参数绑定
* @param testModels
* @return
*/

@RequestMapping(value = "/save",method = RequestMethod.POST)
@Token(remove = true)
public String save(@ModelAttribute TestModel testModels){

logger.info("Info getForm Title:{}",testModels.getTitle());
logger.info("Info getForm Desc:{}",testModels.getDesc());

return "redirect:hello3?id="+ testModels.getTitle();
}

4.4 View视图

在表单中添加如下:

1
<input type="hidden" name="token" value="${token}"/>

五、Javascript防止表单重复提交

设置flag判断是否已经提交

1
2
3
4
5
6
7
8
9
10
11
12
<script language="javascript">   
var checkSubmitFlg = false;
function checkSubmit() {
if (!checkSubmitFlg) {
checkSubmitFlg = true;
return true;
}else{
alert("不能重复提交");
return false;
}
}
</script>

但是,刷新页面后失效。最好前后端一起验证。

六、一点思考

6.1 关于Session

1、由于默认tomcat使用内存管理session,在集群环境下,上述的做法将会存在不一致问题。比如用户从A服务器获取了表单和token,但是提交表单时候却往B服务器提交,这样B服务器判断用户为csrf攻击,所以,用session管理涉及道同步问题。当然,另一个做法是把cookie当session用,把csrf的token放在用户的cookie中。但是,为了避免泄漏token,需要对token进行加密,和进行http only的设置,后者避免js对cookie中的token进行访问。

2、设想攻击者不断请求表单页面之后不做任何操作,则服务器要在一段时间内维持这个session值,如果一段时间大量的请求这个表单页面,则可能会造成内存溢出危险。

3、需要注意一点:就是“并行会话的兼容”。如果用户在一个站点上同时打开了两个不同的表单,CSRF保护措施不应该影响到他对任何表单的提交。考虑一下如果每次表单被装入时站点生成一个伪随机值来覆盖以前的伪随机值将会发生什么情况:用户只能成功地提交他最后打开的表单,因为所有其他的表单都含有非法的伪随机值。必须小心操作以确保CSRF保护措施不会影响选项卡式的浏览或者利用多个浏览器窗口浏览一个站点。

4、验证码方式:
每次的用户提交都需要用户在表单中填写一个图片上的随机字符串,厄….这个方案可以完全解决CSRF,但个人觉得在易用性方面似乎不是太好,还有听闻是验证码图片的使用涉及了一个被称为MHTML的Bug,可能在某些版本的微软IE中受影响。

以上四点是以后学习过程中需要注意并尝试解决的问题,到底信息应该存放在Session还是Cookie。

6.2 关于Cookie

如果Token保存在Cookie中,而不是保存在session中,就会导致,如果一个用户同打开几个相同的页面操作,当某个页面使用过Token之后,其它页面表单的Token值仍为被使用过的那个Token值,当其他页面表单提交时就会出现Token错误。

以上