Spirng Security优势之一就是为各种可能存在的漏洞提供了保护机制,而这些保护机制默认都是开启的。
CSRF(跨站请求伪造) 也可以称为一键式攻击,CSRF攻击时一种挟持用户在当前已登录的浏览器上发送恶意请求的攻击方法,简单来说,就是攻击者通过一些技术手段欺骗用户的浏览器,去访问一个用户曾经认证过的网站并执行恶意请求。由于客户端(浏览器)已经在该网站上认证过,所以该网站会认为时真正的用户在操作而执行请求。
CSRF防御
CSRF攻击的根源在于浏览器默认的身份验证机制(自动携带当前网站的Cookie信息),这种机制虽然可以保证请求是来自用户的某个浏览器,但是无法确保该请求是用户授权发送的。攻击者和用户发送的请求一模一样,这意味着我们没有办法去直接拒绝这里的某一个请求,如果能在合法请求中额外携带一个攻击者无法获取的参数,就可以成功区分出两种不同的请求,进而拒绝恶意请求。
Spring中提供了两种机制来防御CSRF攻击:令牌同步模式和在Cookie上指定SameSite属性
令牌同步模式
这是目前主流的CSRF攻击防御方案。
具体的操作方式就是在每一个HTTP请求中,除了默认自动携带的Cookie参数外,再额外提供一个安全的,随机生成的字符串,我们称之为CSRF令牌。这个CSRF令牌由服务端生成,生成后在HttpSession中保存一份。当前端请求到达后,将请求携带的CSRF令牌信息和服务端保存的令牌进行对比,如果二者不相等,则拒绝该HTTP请求。
考虑到会有一些外部站点链接到我们网站,所以我们要求请求是幂等的,这样对于GET,HEAD,POTIONS,TRACE等方法就没有必要使用CSRF令牌,强行使用可能会导致令牌泄露。(Spring Security默认没有对GET,HEAD,POTIONS,TRACE请求进行CSRF校验)
后端有两种方式传递给前端csrf令牌信息
(1)放在request属性中返回给前端
使用时需要注意前端传给后端csrf令牌信息
<form action="/hello" method="post">
<input type="hidden" th:value="${_csrf.token}" th:name="{_csrf.parameterName}"
<input type="submit" value="hello">
</form>
(2)放在Cookie中
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.csrf()
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
}
需要注意的是,这里将csrfTokenRepository配置为CookieCsrfTokenRepository,并设置为httpOnly属性为false,否则前端将无法读取到Cookie中的CSRF令牌
前端从Cookie中提取令牌信息。
实例:
<script>
$("#loginBtn").click(
function(){
let _csrf=$.cookie('XSRF-TOKEN');
$.post('/login.html',{
_csrf:_csrf;
})
}
)
</script>
这里使用JQuery和JQuery Cookie库 来简化Cookie操作,提取Cookie中的XSRF-TOKEN字段
CSRF令牌放在Cookie中会造成CSRF攻击吗?
答案是不会!CSRF攻击的根源在于浏览器默认的身份认证机制,即发送请求时会自动携带Cookie,但是Cookie的内容是什么黑客是不知道的,必须要经过解析。
SameSite
SameSite是最近几年才出现的一个解决方案,是Chrome 51开始支持的一个属性,用来防止CSRF攻击和用户追踪
这种方式通过在Cookie中指定SameSite属性,要求浏览器从外部站点发送请求时,不应该携带Cookie信息,进而防止CSRF攻击,添加了SameSite属性的响应头
SameSite属性值有三种:
Strict:只有同一站点发送的请求才包含Cookie信息,不同站点发送的Cookie请求将不会包含Cookie信息
Lax:同一站点发送的请求或者导航到目标地址的GET请求会自动包含Cookie信息,否则不包含Cookie信息
None:Cookie将在所有上下文中发送,即允许跨域发送
使用SameSite还有一个需要考虑的因素就是浏览器的兼容性,虽然大部分现代浏览器都支持SameSite属性,但是可能还是存在一些古董级浏览器不支持该属性。所以,如果使用SameSite处理CSRF攻击,建议作为一个备选方案,而不是主要方案
Spring Security对于SameSite并未直接提供支持,但是Spring Session提供了,因此,在使用时,需要先引入Spring Session和Redis依赖
然后提供一个CookieSerializer
@Bean
public CookieSerializer httpSessionIdResolver(){
DefaultCookieSerializer cookieSerializer=new DefaultCookieSerializer();
cookieSerializer.setSameSite("strict");
return cookieSerializer;
}
源码分析
Spring Security提供了CsrfToken接口描述CSRF令牌信息
public interface CsrfToken extends Serializable {
String getHeaderName();
String getParameterName();
String getToken();
}
(1)getHeaderName:当CSRF令牌被放在请求头时,获得参数名
(2)getParameterName:当CSRF令牌被当做请求参数传递时,获取参数名
(3)getToken:获取具体的CSRF令牌
Spring Security提供了CsrfTokenRepository接口保存CsrfToken
public interface CsrfTokenRepository {
CsrfToken generateToken(HttpServletRequest var1);
void saveToken(CsrfToken var1, HttpServletRequest var2, HttpServletResponse var3);
CsrfToken loadToken(HttpServletRequest var1);
}
(1)generateToken:该方法用来生成一个CSRF令牌
(2)saveToken:该方法用来保存CSRF令牌
(3)loadToken:该方法用来读取一个CSRF令牌
共有三个实现类,HttpSessionCsrfTokenRepository(保存在HttpSession中),CookieCsrfTokenRepository(保存在Cookie中) , LazyCsrfTokenRepository(代理类,延迟保存生成的CsrfToken)
在CsrfFilter中校验客户端传来的CSRF令牌,该类继承OncePerRequestFilter,是过滤器链的一环
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
request.setAttribute(HttpServletResponse.class.getName(), response);
CsrfToken csrfToken = this.tokenRepository.loadToken(request);
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)) {
if (this.logger.isTraceEnabled()) {
this.logger.trace("Did not protect against CSRF since request did not match " + this.requireCsrfProtectionMatcher);
}
filterChain.doFilter(request, response);
} else {
String actualToken = request.getHeader(csrfToken.getHeaderName());
if (actualToken == null) {
actualToken = request.getParameter(csrfToken.getParameterName());
}
if (!equalsConstantTime(csrfToken.getToken(), actualToken)) {
this.logger.debug(LogMessage.of(() -> {
return "Invalid CSRF token found for " + UrlUtils.buildFullRequestUrl(request);
}));
AccessDeniedException exception = !missingToken ? new InvalidCsrfTokenException(csrfToken, actualToken) : new MissingCsrfTokenException(actualToken);
this.accessDeniedHandler.handle(request, response, (AccessDeniedException)exception);
} else {
filterChain.doFilter(request, response);
}
}
}
(1)首先调用this.tokenRepository.loadToken方法加载出CsrfToken对象,默认使用的tokenRepository对象类型是LazyCsrfTokenRepository
(2)如果CsrfToken对象不存在,则立马生成CsrfToken对象并保存起来,需要注意,如果tokenRepository类型是LazyCsrfTokenRepository,则这里并未真正将CsrfToken令牌保存起来
(3)将生成的CsrfToken对象设置到request属性中,这样就可以在前端页面中渲染出令牌信息了
(4)调用requireCsrfProtectionMatcher.matches判断请求方法是否是GET,HEAD,TRACE,OPTIONS,如果是则跳过校验(此时使用LazyCsrfTokenRepository的优势就体现出来了,不必再保存CsrfToken信息了)
(5)请求方法如果不是GET,HEAD,TRACE,OPTIONS,则先从请求头中提取出CSRF令牌,请求头没有则从请求参数中提取出CSRF令牌,将拿到的CSRF令牌和第一步中通过loadToken加载出来的令牌进行比对,判断令牌是否合法
CsrfAuthenticationStrategy实现了SessionAuthenticationStrategy接口,在用户登陆成功后触发执行,删除旧的CsrfToken 并生成新的CsrfToken
public final class CsrfAuthenticationStrategy implements SessionAuthenticationStrategy {
private final Log logger = LogFactory.getLog(this.getClass());
private final CsrfTokenRepository csrfTokenRepository;
public CsrfAuthenticationStrategy(CsrfTokenRepository csrfTokenRepository) {
Assert.notNull(csrfTokenRepository, "csrfTokenRepository cannot be null");
this.csrfTokenRepository = csrfTokenRepository;
}
public void onAuthentication(Authentication authentication, HttpServletRequest request, HttpServletResponse response) throws SessionAuthenticationException {
boolean containsToken = this.csrfTokenRepository.loadToken(request) != null;
if (containsToken) {
this.csrfTokenRepository.saveToken((CsrfToken)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);
this.logger.debug("Replaced CSRF Token");
}
}
}