CSRF
CSRF(Cross-Site Request Forgery)跨站请求伪造,是一种挟持用户在当前已登录的浏览器上发送恶意请求的攻击方法,比如攻击者通过一些技术手段欺骗用户的浏览器,去访问一个用户曾经认证过的网站并执行恶意请求。由于客户端已经在该网站认证过,所以该网站会认为是用户在操作二执行恶意请求。
CSRF的根源在于浏览器默认的身份验证机制(自动携带当前网站的Cookie信息),这种机制虽然可以保证请求是来自用户的某个浏览器,但是无法确保该请求是用户授权发送的,攻击者和用户发送的请求一模一样,如果在合法请求中额外携带一个攻击者无法获取的参数就可以成功区分出两种不同的请求,进而拒绝恶意请求
Spring 提供了两种机制:
- 令牌同步模式
- 在Cookie上知道SameSite属性
令牌同步模式
在每一个HTTP请求中,除了默认的自动携带的Cookie参数之外,再额外提供一个安全的,随机生成的字符串,我们称之为CSRF令牌。这个令牌由服务端生成,生成后在HttpSession中保存一份。当前请求到达之后,将请求携带的CSRF令牌信息和服务端中的令牌对比,如果两者不相等,则拒绝该Http请求。
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<form action="/hello" method="post">
<input type="hidden" th:value="${_csrf.token}" th:name="${_csrf.parameterName}">
<input type="submit" value="hello">
</form>
</body>
</html>
form表单隐藏域中对应的key和value都是服务端默认返回的变量,只需要填充变量名即可。
请求方法要幂等性,所以Spring Security默认不会对GET,HEAD,OPTIONS以及TRACE请求进行CSRF令牌校验。
.csrf().disable();为关闭CSRF攻击防御功能,默认开启。
对应form表单请求,服务端返回的CSRF令牌,放在request属性中返回给前端。
对于Ajax请求,将CSRF令牌放在响应头Cookie中,开发者从Cookie中提取CSRF令牌信息,然后作为参数提交到服务端。
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.loginProcessingUrl("/login.html")
.successHandler((req,resp,auth)->{
resp.getWriter().write("login success");
})
.permitAll()
.and()
.headers()
.and()
.csrf()
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
}
csrfTokenRepository为CookieCsrfTokenRepository 设置HttpOnly为false,否则前端无法获取到Cookie中CSRF令牌
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-cookie/1.4.1/jquery.cookie.min.js"></script>
</head>
<body>
<div>
<input type="text" id="username">
<input type="password" id="password">
<input type="button" value="登录" id="loginBtn">
</div>
<script>
$("#loginBtn").click(function () {
let _csrf = $.cookie('XSRF-TOKEN');
$.post('/login.html', {
username: $("#username").val(),
password: $("#password").val(),
_csrf: _csrf
}, function (data) {
alert(data);
})
})
</script>
</body>
</html>
区别:form表单服务端将CSRF令牌保存到HttpSession中,ajax请求是服务端将CSRF令牌放在Cookie中。
在Cookie上知道SameSite属性
通过在Cookie上指定SameSite属性,要求浏览器从外部站点发送请求时,不应携带Cookie信息,进而防止CSRF攻击。
SameSite属性:
- Strict:只有同一站点发送的请求才包含Cookie信息,不同站点发送的请求不会包含Cookie信息。
- Lax:同一站点发送的请求或者导航到目标地址的GET请求会自动包含Cookie信息,否则不包含Cookie信息。
- None:Cookie将从所有上下文中发送,即允许跨域发送。
@Bean
public CookieSerializer httpSessionIdResolver(){
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
cookieSerializer.setSameSite("strict");
return cookieSerializer;
}
CsrfFilter过滤器
CsrfFilter是Spring Security过滤器链中的一环,在过滤器中校验客户端传来的CSRF令牌是否有效。
CsrfFilter过滤器是由CsrfConfigurer进行配置的,而CsrfConfigurer是在WebSecurityConfigurerAdapter的getHttp方法中添加进HttpSecurity中的。
CsrfFilter继承OncePerRequestFilter,最重要的方法就是doFilterInternal了
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
request.setAttribute(HttpServletResponse.class.getName(), response);
CsrfToken csrfToken = this.tokenRepository.loadToken(request);
final boolean missingToken = csrfToken == null;
if (missingToken) {
csrfToken = this.tokenRepository.generateToken(request);
this.tokenRepository.saveToken(csrfToken, request, response);
}
request.setAttribute(CsrfToken.class.getName(), csrfToken);
request.setAttribute(csrfToken.getParameterName(), csrfToken);
if (!this.requireCsrfProtectionMatcher.matches(request)) {
filterChain.doFilter(request, response);
return;
}
String actualToken = request.getHeader(csrfToken.getHeaderName());
if (actualToken == null) {
actualToken = request.getParameter(csrfToken.getParameterName());
}
if (!csrfToken.getToken().equals(actualToken)) {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Invalid CSRF token found for "
+ UrlUtils.buildFullRequestUrl(request));
}
if (missingToken) {
this.accessDeniedHandler.handle(request, response,
new MissingCsrfTokenException(actualToken));
}
else {
this.accessDeniedHandler.handle(request, response,
new InvalidCsrfTokenException(csrfToken, actualToken));
}
return;
}
filterChain.doFilter(request, response);
}
- 首先调用tokenRepository.loadToken方法进行加载出CsrfToken对象 CsrfToken为接口,用来描述CSRF令牌信息,默认tokenRepository对象类型为LazyCsrfTokenRepository,CsrfTokenRepository是SpringSecurity中提供的CsrfToken保存接口,实现类有HttpSessionCsrfTokenRepository,CookieCsrfTokenRepository,LazyCsrfTokenRepository。HttpSessionCsrfTokenRepository是将 CsrfToken保存在HttpSession中,CookieCsrfTokenRepository是将CsrfToken保存在Cookie中,LazyCsrfTokenRepository是一个代理类,可以代理HttpSessionCsrfTokenRepository或者CookieCsrfTokenRepository,代理目的是延迟保存生成的CsrfToken。
- 如果CsrfToken对象不存在,则立马生成CsrfToken对象并保存起来。
- 将生成的CsrfToken对象设置到request属性中,这样我们在前端页面中就可以渲染出生成的令牌信息了。
- 调用requireCsrfProtectionMatcher.matches方法进行请求判断,该方法主要判断当前请求方法是否为GET,HEAD,TRACE以及OPTIONS。如果当前请求方法是这四种之一,则请求直接过,不用进行CSRF的令牌校验,上一步没有必须进行保存CsrfToken,LazyCsrfTokenRepository生成了CsrfToken令牌没有立即保存,而是后面调用getToken时才保存。
- 如果请求不是GET,HEAD,TRACE以及OPTIONS,先从请求头中提取出CSRF令牌,请求头没有,则从请求参数中提取出CSRF令牌,将拿到的CSRF令牌和第1步中通过loadToken加载出来的令牌进行对比,判断请求传来的CSRF令牌是否合法。
总结:请求到达后,会经过CsrfFilter,在该过滤器中,首先加载出保存的CsrfToken,可以是从HttpSession中加载,也可以是从请求头携带的Cookie中加载,默认是从HttpSession中加载,如果加载出来的CsrfToken为null,则立即生成一个CsrfToken并保存起来,由于默认tokenRepository类型是LazyCsrfTokenRepository,所以这里的保存并不是真正的保存,因为如果请求方法是GET,HEAD,TRACE以及OPTIONS,就没有必要保存。然后将生成的CsrfToken放到请求对象中,方面前端渲染。然后判断请求方法是否是需要进行CSRF令牌校验的方法,如果不是,则直接执行后面的过滤器,否则就从请求中拿出CSRF令牌信息和一开始加载出来的令牌进行对比。
CsrfAuthenticationStrategy
CsrfAuthenticationStrategy实现了SessionAuthenticationStrategy接口,默认也是由CompositeSessionAuthenticationStrategy代理执行,登录成功后触发执行,CsrfAuthenticationStrategy主要用于在登录成功后,删除旧的CsrfToken并生成一个新的CsrfToken
public void onAuthentication(Authentication authentication,
HttpServletRequest request, HttpServletResponse response)
throws SessionAuthenticationException {
boolean containsToken = this.csrfTokenRepository.loadToken(request) != null;
if (containsToken) {
this.csrfTokenRepository.saveToken(null, request, response);
CsrfToken newToken = this.csrfTokenRepository.generateToken(request);
this.csrfTokenRepository.saveToken(newToken, request, response);
request.setAttribute(CsrfToken.class.getName(), newToken);
request.setAttribute(newToken.getParameterName(), newToken);
}
}