Spring对CSRF的防范

这篇基本上是Spring Security Reference关于 CSRF 部分的一个笔记,只是记录一下核心逻辑。其它很多细节还是要参考官方文档。

什么是 CSRF

跨站请求伪造。经典场景是:

1)受害者首先登录了银行网站
2)用户没有 longout 的情况下
3)用同一个浏览器访问了“坏”网站
4)“坏”网站有一个向银行网站提交业务请求的页面
5)诱使用户发送这个请求。

实际上利用有 XSS 漏洞,完全可以无需受害者参与利用 javascript 而自动触发第 4 第 5 步。

这个场景背后有些逻辑:

这里我们把浏览器等同于用户,有些数据是用户自己可见的,有些数据是浏览器自动处理、发送而用户对这些数据是无感知的(比如 SessionId)。
银行网站以 cookie 的形式把 sessionId 发送给浏览器(set-cookie),浏览器每次请求都会再自动带上 cookie(cookie)。
上面场景第 5 步虽然“坏”表单不是源于银行网站页面而是在第三方网站的页面上,但是浏览器发现目标地址是银行网站,因此会自动带上响应的 cookie,比如 JSESSION 就会随带着被发送了。从服务器的角度看,来自第 4 步的数据与正常数据没有任何差别,因为这个业务请求便会被执行。

归根结底,这种 CSRF 的问题是 cookie 和浏览器工作方式引起的。

解决方案

一个是主流的“Synchronizer Token Pattern”方法,另一个是渐成主流的“SameSite Attribute”。

因为SameSite Attribute的方式实施和理解比较容易,我们先说。
服务端利用 cookie 的 SameSite 属性可以禁止浏览器从外部站点发送请求时带上 cookie。比如下面的 cookie 就不会被放在由在第三方网页发起而目标是银行网站的请求上。这就自然解决了上面的 CSRF 的问题。

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

Synchronizer Token Pattern
这个解决办法的原理是对来自浏览器的请求我们都回送一个随机数,下次浏览器再请求业务时需要在 header 里或者表单里带上这个随机数。这个随机数就是 csrf token。
这个办法之所以能防范 CSRF,是因为 sessionId 来自 cookie,而 csrf token 来自 header 或者 form。相当于分别在两条不同的路径上传递。

Spring Security 模块生成 csrf token 后可放在两个地方。Spring 默认的,随机数与 sessionId 关联,放在 session 里。另一个方式:随机数放在 cookie 里。

基于 Session 保存 csrf token

与 session 关联比较容易理解,下次浏览器发送请求过来,服务端就可以从 header 或 form 里取出来的 csrf token 与 session 中的随机数相比较来进行判断。

通过 cookie 保存 csrf 是怎么回事呢?如果 csrf token 通过 cookie 发送给浏览器,那这个随机数不就跟 JSESSIONID 一样了会被浏览器自动传回到服务器了吗?
是的,这个 csrf 会被浏览器传回给服务器,我们也正是利用这一点“保存”了 csrf token。之所以使用基于 cookie 的方式,是因为要针对前后端分离的情形让前端可以使用 javascript 获得 csrf token,并把这个 token 作为下次请求的 header 参数或者 form 参数传递给服务端。服务端所要做的就是对比来自 cookie 的 csrf token 和来自 header/form 里的 csrftoken 来做出判断。

下面代码设置使用 cookie 保存,注意使用 cookie 传递 token 需要把 cookie 的 HttpOnly 属性设置为 false,以便让 javascript 能读到此值。

@EnableWebSecurity
public class WebSecurityConfig extends
        WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) {
        http
            .csrf(csrf -> csrf
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
            );
    }
}