Spring Security 跨站请求伪造保护

1. 前言

很多小伙伴在开发 Spring Security 项目时候,本地测试都没有问题,一放到生产环境后,就会遇到「Invalid CSRF Token」问题,这其实是 Spring Security 防止服务免受「跨站请求伪造」攻击攻击的防护行为。

图片描述

跨站请求伪造(Cross Site Request Forgery),简写成「CSRF」或者「XSRF」,是一种挟持用户所用浏览器,执行非法操作的攻击方法,也就是说,攻击者利用「CSRF」漏洞伪造用户操作,可实现例如购物、注销等效果,还可以利用该漏洞配合产生其他多种攻击方式。

针对「CSRF」攻击最经济的解决方式是增加「Referer」头或者增加校验「Token」。

Spring Security 提供了针对「CSRF」的规范化防御手段,本节我们主要讨论如何使用 Spring Security 的内置功能防御「CSRF」攻击。

2. CSRF 攻击原理

我们用一个实例演示「CSRF」攻击的过程。

图片描述

假设我们登陆了一个银行网站(bank.example.com),这个网站的作用是实现跨行转账的表单提交,通常情况下,我们会生成如下一个 Form 表单。

<form method="post" action="/transfer">
  <!-- 汇款金额 -->
	<input type="text" name="amount"/>
  <!-- 汇款路由号 -->
	<input type="text" name="routingNumber"/>
  <!-- 汇款账户 -->
	<input type="text" name="account"/>

	<input type="submit" value="提交"/>
</form>

那我们发出的「post」请求格式可能如下:

POST /transfer HTTP/1.1
Host: bank.example.com
Cookie: JSESSIONID=randomid
Content-Type: application/x-www-form-urlencoded

amount=100.00&routingNumber=1234&account=9876

此时,如果我们未登出,并且访问了其他恶意网站,并且其他恶意网站同样包含了可提交的表单,表单形式如下:

<form method="post" action="https://bank.example.com/transfer">
  <!-- 隐藏项不可见,转账金额,固定 100 元 -->
	<input type="hidden" name="amount" value="100.00"/>
  <!-- 隐藏项不可见,转账路由码 -->
	<input type="hidden" name="routingNumber" value="evilsRoutingNumber"/>
  <!-- 隐藏项不可见,转账账户 -->
	<input type="hidden" name="account" value="evilsAccountNumber"/>
  <!-- 可见 -->
	<input type="submit" value="快来点我!"/>
</form>

当我们很好奇,点击了「快来点我」按钮时,我们会触发转账请求,并将钱汇款到一个未知账户里。在这个过程中,虽然恶意网站并不知道我们的「Cookies」值,但是由于未登出,我们和银行网站之间的 Cookies 还在,所以当我们再次发起请求时,该 Cookies 依然有效,这使得不知不觉被触发的转账请求同样有效。

除此之外,如果恶意网站使用 JS 脚本自动提交表单的话,用户可能没有任何被攻击的感觉。

3. CSRF 攻击的防御

我们虽然无法阻止恶意网站向目标网站发送 HTTP 请求,但是我们可以确保恶意网站无法生成目标网站所需的参数,所以就出现了如下两种常见的解决方案:

  • 使用同步「Token」模式
  • 在 Cookies 中指定网站同源的参数

这两种方式在 Spring Security 中都已支持。

3.1 CSRF 保护的前提

要实现 CSRF 保护,首先我们要确保安全方法是幂等的。安全方法包括「GET」,「HEAD」,「OPTIONS」,「TRACE」,幂等是指这些方法在反复发送后服务器状态不会改变。

3.2 同步「Token」模式

这种方法时防御「CSRF」攻击的最常用手段,它的原理是确保项目表网站发送的请求中,每次都包含一个被称为「CSRF Token」的随机参数。

每当请求提交到服务端后,服务端会对比请求中包含的「CSRF Token」和期望的是否匹配,如果不匹配,此请求作废。这里的关键是,「CSRF Token」不能由浏览器生成。

来看一下代码:

<form method="post" action="/transfer">
  <!-- csrf 头 -->
	<input type="hidden" name="_csrf" value="4bfd1575-3ad1-4d21-96c7-4ef2d9f86721"/>
  <!-- 汇款金额 -->
	<input type="text" name="amount"/>
  <!-- 汇款路由号 -->
	<input type="text" name="routingNumber"/>
  <!-- 汇款账户 -->
	<input type="text" name="account"/>
	<input type="submit" value="提交"/>
</form>

上述代码中增加了 _csrf 参数,且该值由服务器生成并埋在页面表单中,此时 HTTP 请求的内容如下:

POST /transfer HTTP/1.1
Host: bank.example.com
Cookie: JSESSIONID=randomid
Content-Type: application/x-www-form-urlencoded

amount=100.00&routingNumber=1234&account=9876&_csrf=4bfd1575-3ad1-4d21-96c7-4ef2d9f86721

由于浏览器同源策略,我们不用担心恶意网站可以得到 _csrf 的值,而且恶意网站无法伪造出于服务器想匹配的「CSRF Token」,这样就保护了用户信息的安全。

3.3 SameSite 参数

另一种避免「CSRF」攻击的方法是使用 SameSite 参数。这是一种新的方式,有赖于浏览器的支持。这种情况下服务端会指定一个 SameSite 参数来保障请求不会来自于非可信网站。

例如,包含 SameSite 参数的响应消息如下:

Set-Cookie: JSESSIONID=randomid; Domain=bank.example.com; Secure; HttpOnly; SameSite=Lax

其中 SameSite 属性支持的值包括:

  • 严格模式(Strict)。仅 URL 相匹配的网站才支持发送 Cookie,任何其他网站都不会发生 Cookie。
  • 宽松模式(Lax)。相比严格模式,宽松模式下支持部分非相同网站的请求中携带 Cookie,比如超链接方式跳转、预加载、GET请求。

采用这种方式后,来自恶意网站的请求无法加上 JSESSIONID 参数,由此避免了「CSRF」攻击。

4. Spring Security 配置方法

默认情况下,Spring Security 已开启「CSRF」保护,这里我们罗列一下其它常用配置。

4.1 自定义 Token 仓库

默认情况下,CSRF Token 存储在 HttpSession 中,使用 HttpSessionCsrfTokenRepository 对象维护。如果需要进行扩展,比如不仅要在 HTTP 请求中携带 Token,也需要在 JS 应用中应用 Token,那需要通过如下方式:

在配置类中构造并注入 CookieCsrfTokenRepository 对象。

@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) {
        http.csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()));
    }
}

4.2 禁用 CSRF 保护

默认情况下,CSRF 保护功能已被开启,如果需要关闭,可通过如下方式配置:

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) {
        http.csrf(csrf -> csrf.disable());
    }
}

4.3 携带 CSRF Token

为了在 HTTP 请求中携带 CSRF Token,我们必须要对 HTTP Request 做一些配置,因为它默认是不会携带 CSRF 相关参数的。默认情况下,Spring Security 中有 CsrfFilter 判断请求中是否有 _csrf 参数,通常请求来自于两种情况,Form 表单提交或者 Ajax。

4.3.1 Form 表单提交

使用 Form 表单提交代码时,我们需要在 Form 参数中增加一个隐藏项:_csrf,例如:

<input type="hidden"
    name="_csrf"
    value="4bfd1575-3ad1-4d21-96c7-4ef2d9f86721"/>

这里的 _csrf 有几种配置方式:

  • 自动注入

    Spring Security 通过扩展 Spring 的 RequestDataValueProcessor 类,实现了 RequestDataValueProcessor 类,这意味着如果我们使用 Spring 标签库、Thymeleaf 模板插件、或者其它集成了 RequestDataValueProcessor 对象的视图组件是,表单的非幂等请求(例如:POST)都会自动携带 CSRF Token。

  • JSP 标签

    针对 JSP 作为页面开发基础,我们可以直接使用 Spring 的表单标签库或者 CsrfInput 标签。也可以通过更加直接的方式,在使用 HttpServletRequest 属性 _csrf,代码如下:

    <c:url var="logoutUrl" value="/logout"/>
    <form action="${logoutUrl}" method="post">
    <input type="submit" value="登出" />
    <input type="hidden"
        name="${_csrf.parameterName}"
        value="${_csrf.token}"/>
    </form>
    

4.3.2 Ajax 和 JSON 请求

如果使用 Javascript 做为请求提交方式,我们没法直接使用 Http CSRF 参数,取而代之的是使用 Http 头的方式。这同样也有几种方法:

  • 自动注入

    Spring Security 可以自动将 CSRF Token 保存到 Cookie 中,一些客户端框架如 AngularJS 会自动从中得到 CSRF Token 并放置到请求头中。

  • Meta 标签

    另一种方式是从 Cookie 中解压 Token 并使用 Meta 标签,如下:

    <html>
    <head>
        <meta name="_csrf" content="4bfd1575-3ad1-4d21-96c7-4ef2d9f86721"/>
        <meta name="_csrf_header" content="X-CSRF-TOKEN"/>
        <!-- ... -->
    </head>
    

    当 Meta 标签中有 Token 信息时,我们就可以将 Meta 中的 CSRF Token 值用作请求参数了。以 JQuery 为例:

    $(function () {
        var token = $("meta[name='_csrf']").attr("content");
        var header = $("meta[name='_csrf_header']").attr("content");
        $(document).ajaxSend(function(e, xhr, options) {
            xhr.setRequestHeader(header, token);
        });
    });
    

5. 小结

本节我们讨论了 CSRF 的含义及在 Spring Security 中配置方法:

  • CSRF 是一种常见的 B/S 攻击形式;
  • CSRF 可以在浏览器上伪造用户的请求;
  • CSRF 的防御思路是确保发送请求的来源是可信的;
  • CSRF 的防御方法包含「同步 Token」和「设置 SameSite 参数」两种,其中「SameSite」参数方式需要浏览器的支持;
  • Spring Security 默认已开启 CSRF 保护。

下节我们讨论 Spring Security 对 HTTP 请求常用的和安全相关的头部信息。