Spring Security 配置 CSRF 防御的原理与实战

Version: 5.7.11

1 CSRF 简介

跨站请求伪造 (CSRF, Cross-Site Request Forgery) 是一种攻击手段,它利用 Web 应用程序对已经过身份验证的用户的信任,以这些用户的名义向通过用户认证的 Web 应用程序提交非法请求。CSRF 运行流程图如下:

datagrip
  • 用户浏览并登录信任的网站 A,通过用户认证后,会在浏览器中生成针对 A 网站的 Cookies;
  • 用户在没有退出网站 A 的情况下访问网站 B,然后网站 B 要求浏览器向网站 A 发起一个请求;
  • 浏览器根据网站 B 的请求,携带 Cookies 访问网站 A;
  • 由于浏览器会自动带上用户的 Cookies,所以网站 A 接收到请求之后会根据用户具备的权限进行访问控制,这样相当于用户本身在访问网站 A,从而网站 B 实现了模拟用户访问网站 A 的操作过程。

由上可知,防止 CSRF 攻击的关键在于确认 HTTP 请求是否是通过 APP 的用户界面合法生成的,实现此目的的最佳方法是通过 CSRF 令牌:

  • 后端服务为每个用户会话分配一个唯一的 CSRF 令牌,然后发送至浏览器;
  • 浏览器后续访问敏感内容时,必须携带这个令牌,如果没有令牌或者令牌不匹配,请求将被服务器拒绝。

2 Spring Security 配置 CSRF 防御

2.1 通过 WebSecurityConfigurerAdapter 配置

5.7.0-M2 版本以前的 Spring Security 推荐通过配置类定义 CSRF 防御行为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@Configuration
@EnableWebSecurity
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf(c -> 
            // 注册一个 CsrfTokenRepository 实现类
            c.csrfTokenRepository(new XxxCsrfTokenRepository()))
        .xxx;
    }
}

从 Spring Security 4.0 开始,默认启用了 CSRF 防护功能,如果想关闭可以在配置类中调用以下方法:

1
http.csrf().disable();

2.2 通过创建 SecurityFilterChain Bean 配置

从 Spring Security 5.7.0-M2 开始,WebSecurityConfigurerAdapter 被废弃了,因此在新版本中更推荐通过创建 SecurityFilterChain Bean 来配置 CSRF,示例如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Spring Security 5.4 版本被引入。
@Configuration
public class WebSecurityConfigurer {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf(c -> 
            // 注册一个 CsrfTokenRepository 实现类
            c.csrfTokenRepository(new XxxCsrfTokenRepository()))
        .xxx;
        return http.build();
    }
}

接下来,我们详细介绍以下 Spring Security 的 CSRF 防御实现原理。

3 CsrfFilter 防御原理

Spring Security 通过 CsrfFilter 实现 CSRF 防护。CsrfFilter 会直接放行 GETHEADTRACEOPTIONS 等请求,同时要求可能会修改数据的 PUTPOSTDELETE 等请求包含 CSRF Token 请求头或参数。如果 CSRF Token 不存在或值不正确,则拒绝该请求并将响应的状态设置为 403。

CSRF Token 本质就是一个字符串,Spring Security 专门定义了一个 CsrfToken 接口来规定 Token 获取方式:

1
2
3
4
5
6
7
8
public interface CsrfToken extends Serializable {
	// 获取请求头名称
	String getHeaderName();
	// 获取应该包含 Token 的参数名称
	String getParameterName();
	// 获取具体的 Token 值
	String getToken();
}

CsrfFilter 类处理 CsrfToken 的流程如下:

 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
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
        throws ServletException, IOException {
    request.setAttribute(HttpServletResponse.class.getName(), response);
    // 从 CsrfTokenRepository 中获取当前用户的 CsrfToken
    CsrfToken csrfToken = this.tokenRepository.loadToken(request);
    boolean missingToken = (csrfToken == null);
    // 如果找不到 CsrfToken 就生成一个并保存到 CsrfTokenRepository 中
    if (missingToken) {
        csrfToken = this.tokenRepository.generateToken(request);
        this.tokenRepository.saveToken(csrfToken, request, response);
    }
    // 在请求中添加 CsrfToken
    request.setAttribute(CsrfToken.class.getName(), csrfToken);
    request.setAttribute(csrfToken.getParameterName(), csrfToken);
    // 如果是 "GET", "HEAD", "TRACE", "OPTIONS" 这些方法,直接放行
    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);
        return;
    }
    // 从用户请求头中获取 CsrfToken
    String actualToken = request.getHeader(csrfToken.getHeaderName());
    if (actualToken == null) {
        // 头信息中拿不到,再从 param 中获取一次
        actualToken = request.getParameter(csrfToken.getParameterName());
    }
    // 如果请求所携带的 CsrfToken 与从 Repository 中获取的不同,则阻止访问
    if (!equalsConstantTime(csrfToken.getToken(), actualToken)) {
        this.logger.debug(
                LogMessage.of(() -> "Invalid CSRF token found for " + UrlUtils.buildFullRequestUrl(request)));
        AccessDeniedException exception = (!missingToken) ? new InvalidCsrfTokenException(csrfToken, actualToken)
                : new MissingCsrfTokenException(actualToken);
        this.accessDeniedHandler.handle(request, response, exception);
        return;
    }
    // 正常情况下继续执行过滤器链的后续流程
    filterChain.doFilter(request, response);
}

整个过滤器的执行流程基本就是围绕 CsrfToken 的校验展开的。

4 CsrfTokenRepository

观察上述代码,我们可以注意到存在一个名为 CsrfTokenRepository 的组件,该组件用于管理 CsrfToken 的存储:

1
2
3
4
5
6
7
8
9
public interface CsrfTokenRepository {
	// 生成新的 token
	CsrfToken generateToken(HttpServletRequest request);
	// 保存 token,如果 token 传入 null 等同于删除
	void saveToken(CsrfToken token, HttpServletRequest request,
            HttpServletResponse response);
	// 从目标地点获取 token
	CsrfToken loadToken(HttpServletRequest request);
}

接下来,我们详细介绍下 Spring Security 提供的几个默认实现类。

4.1 CookieCsrfTokenRepository

CookieCsrfTokenRepository 是基于 Cookies 实现的 Token 注册工具,首先我们关注它的 saveToken 方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@Override
public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) {
    // 判断参数 token 是否为空
    String tokenValue = (token != null) ? token.getToken() : "";
    // 根据 token,创建 Cookies
    Cookie cookie = new Cookie(this.cookieName, tokenValue);
    cookie.setSecure((this.secure != null) ? this.secure : request.isSecure());
    cookie.setPath(StringUtils.hasLength(this.cookiePath) ? this.cookiePath : this.getRequestContext(request));
    cookie.setMaxAge((token != null) ? this.cookieMaxAge : 0);
    cookie.setHttpOnly(this.cookieHttpOnly);
    if (StringUtils.hasLength(this.cookieDomain)) {
        cookie.setDomain(this.cookieDomain);
    }
    // 最终返回给浏览器
    response.addCookie(cookie);
}

可见,CookieCsrfTokenRepository 将 CSRF 令牌存到了 Cookies 中返回给浏览器,后续的请求过程中,服务器再校验请求中的 Cookies 是否满足条件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Override
public CsrfToken loadToken(HttpServletRequest request) {
    // 获取请求 Cookies
    Cookie cookie = WebUtils.getCookie(request, this.cookieName);
    if (cookie == null) {
        return null;
    }
    // 获取 Cookeis 中的 Token
    String token = cookie.getValue();
    if (!StringUtils.hasLength(token)) {
        return null; // 为空
    }
    // 获取到以后,创建 Token 对象
    return new DefaultCsrfToken(this.headerName, this.parameterName, token);
}

4.2 HttpSessionCsrfTokenRepository

HttpSessionCsrfTokenRepository 是基于会话 Session 实现的 Token 注册工具,首先我们关注它的 saveToken 方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Override
public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) {
    // 如果传入 token 为空,则删除当前会话的 Session
    if (token == null) {
        HttpSession session = request.getSession(false);
        if (session != null) {
            session.removeAttribute(this.sessionAttributeName);
        }
    }
    else {
        // 否则将 token 存入当前会话
        HttpSession session = request.getSession();
        session.setAttribute(this.sessionAttributeName, token);
    }
}

该方法将传入的 Token 存储在了当前 Request 会话的 Session 中。后续调用时再根据 Request 获取 Token:

1
2
3
4
5
6
7
8
9
@Override
public CsrfToken loadToken(HttpServletRequest request) {
    HttpSession session = request.getSession(false);
    if (session == null) {
        return null;
    }
    // 获取会话中的 Token 对象
    return (CsrfToken) session.getAttribute(this.sessionAttributeName);
}

4.3 自定义 CsrfTokenRepository

上文我们介绍了 Spring Security 默认提供的 CookieCsrfTokenRepositoryHttpSessionCsrfTokenRepository 实现类,这些方案有个共同缺点就是无法适应分布式环境,所以实际开发中我们往往需要根据需求设定自己的实现类。

这里我们尝试通过 Spring Data JPA 把 CSRF Token 保存到数据库中,首先我们通过扩展 JpaRepository 来定义一个 JpaTokenRepository

1
2
3
public interface JpaTokenRepository extends JpaRepository<Token, Integer> {
    Optional<Token> findTokenByIdentifier(String identifier);
}
  • 这里只配置一个根据 identifier 获取 Token 的方法。其他的方法都使用 JpaRepository 默认提供的。

然后我们基于 JpaTokenRepository 来构建一个 DatabaseCsrfTokenRepository 实现类:

 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
public final class DatabaseCsrfTokenRepository implements CsrfTokenRepository {

    private JpaTokenRepository jpaTokenRepository;
    @Autowired
    public void setJpaTokenRepository(JpaTokenRepository jpaTokenRepository) {
        this.jpaTokenRepository = jpaTokenRepository;
    }

	@Override
	public CsrfToken generateToken(HttpServletRequest request) {
		return new DefaultCsrfToken("X-XSRF-TOKEN", "_csrf", createNewToken());
	}

	@Override
	public void saveToken(CsrfToken token, HttpServletRequest request,
			HttpServletResponse response) {
		String identifier = request.getHeader("X-IDENTIFIER");
        Optional<Token> existingToken = jpaTokenRepository.findTokenByIdentifier(identifier);

        if (existingToken.isPresent()) {
            // token 已经存在,直接使用存在的
            Token token = existingToken.get();
            token.setToken(csrfToken.getToken());
        } else {
            // 否则创建 token,入库
            Token token = new Token();
            token.setToken(csrfToken.getToken());
            token.setIdentifier(identifier);
            jpaTokenRepository.save(token);
        }
    }

	@Override
	public CsrfToken loadToken(HttpServletRequest request) {
        String identifier = request.getHeader("X-IDENTIFIER");
        Optional<Token> existingToken = jpaTokenRepository.findTokenByIdentifier(identifier);

        if (existingToken.isPresent()) {
            // 查库查到了 token,则构造对象返回
            Token token = existingToken.get();
            return new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", token.getToken());
        }

        return null;
	}
}

DatabaseCsrfTokenRepository 类的代码基本都是自解释的,这里借助了 HTTP 请求中的 X-IDENTIFIER 请求头来确定请求的唯一标识,从而将这一唯一标识与特定的 CsrfToken 关联起来,然后使用 JpaTokenRepository 完成持久化工作。

5 CSRF 防御配置结果测试

5.1 靶服务部署

我们首先设置两个用于测试的 Rest 接口:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@RestController
public class DemoController {
    // 主页
    @GetMapping("/")
    public String home() {
        return "hello";
    }
    // post 请求端点
    @PostMapping("/endpoint")
    public String endpoint() {
        return "success";
    }
}

然后在配置类中启用 CookieCsrfTokenRepository 以及表单登录:

 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
@Configuration
@EnableWebSecurity
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().csrfTokenRepository(new CookieCsrfTokenRepository()).and()
                .authorizeRequests()
                .anyRequest().authenticated()
                .and().formLogin()
                .defaultSuccessUrl("/") //登录认证成功后的跳转页面
                .and().httpBasic();
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
        .withUser("user").password("password").authorities("ROLE_USER")
        .and().passwordEncoder(new PasswordEncoder() {
            @Override
            public String encode(CharSequence rawPassword) {
                return rawPassword.toString();
            }

            @Override
            public boolean matches(CharSequence rawPassword, String encodedPassword) {
                return rawPassword.equals(encodedPassword);
            }
        });
    }
}

mvn 打包工程后,将其部署在服务器上,假设访问域名为 http://csrf.example.com

5.2 编写跨站 HTML

为了方便测试,我们简单做一个 HTML 文件,该文件通过 form 表单向靶服务发送跨站 POST 请求:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<!DOCTYPE html>
<html>
<head>
    <title>CSRF Test</title>
</head>
<body>
    <h1>CSRF Test Page</h1>
    <form action="http://csrf.example.com/endpoint" method="POST" id="form">
        <button type="submit">Send POST Request to other web</button>
    </form>
</body>
</html>

5.3 完成测试

首先我们输入配置的用户名与密码登录 http://csrf.example.com/,然后打开上边的 HTML 文件,点击 Send POST Request to other web 按钮,请求成功被拦截,并重定向 (302) 到了 /login 登录页面。

如果关闭 CSRF 配置(http.csrf().disable()),再重新测试上述流程,就会发现跨站请求成功访问到了 Web 根目录,并没有被拦截重定向。

至此,与 CSRF 相关的内容就介绍完了。


欢迎关注我的公众号,第一时间获取文章更新:

微信公众号

相关内容