Spring Security でCSRFトークンを使ってみる

Spring Security を使ってCSRFトークンを使ってみたという話です。

CSRFとは

Cross Site Request Forgery (CSRF) とは、ログインした状態のユーザが悪意のあるリンク等をクリックすることで、意図しない操作をアプリケーションに送信する攻撃のことです。受け取ったアプリケーションはリクエストを正当なものか、不当なものか判断できないので意図しない操作を実行してしまうことになります。

CSRFについての解説は以下のサイトがわかりやすいと思います。

クロスサイトリクエストフォージェリ - Wikipedia

Spring Security リファレンス - ドキュメント

Spring Security でCSRF攻撃を防ぐ

Spring Security にはCSRF攻撃を防ぐ機能が実装されています。CSRF攻撃を防ぐには一般的に次の2つの方法があります。

Spring Security ではシンクロナイザートークンパターンを使った実装が提供されているのでこれを使ってみましょう。以下の公式リファレンスにはシンクロナイザートークンパターンの解説が載っています。

https://spring.pleiades.io/spring-security/site/docs/current/reference/html5/#csrf-protection-stp

シンクロナイザートークンパターンを使ったCSRF攻撃の防ぎ方を簡単に説明するとこんな感じになります。

f:id:bau1537:20201123141120p:plain

  1. クライアントがGETリクエストをWebアプリケーションに送信する。
  2. WebアプリケーションはCSRFトークンを生成して、HTTPセッションに保存する。
  3. WebアプリケーションはCSRFトークンをクライアントに返す。
  4. クライアントはPOSTリクエスト(他、状態を変更するリクエストでも同じ)に受け取ってあるCSRFを付けて送信する。
  5. Webアプリケーションは受け取ったCSRFトークンとHTTPセッションに保存しておいたCSRFトークンが同一か検証する。同一でなければ不正なリクエストとして403ステータスをクライアントに返却する。

CSRFトークンとはランダムな文字列のことです。Spring Security ではデフォルトでUUIDによるCSRFトークンを生成するようになっています。

一連の流れで重要なのは、クライアントがPOSTリクエストを送信する前に、GETリクエストをWebアプリケーションに対して送信してCSRFトークンを受け取っているところです。CSRFトークンは悪意のあるリクエストと正当なリクエストを区別するために発行される一時的な証明書のようなものなので、一度GETリクエストを行ってCSRFトークンを取得しなくてはいけません。GETリクエストを事前に行ってCSRFトークンを取得することで、悪意のあるリンクをユーザーがクリックし、ユーザーになりすましたリクエストがWebアプリケーションに送信されたとしても、CSRFトークンが含まれていないリクエストは拒否される事になり、CSRF攻撃を防ぐことができるのです。

プロジェクトの設定

環境は以下の通り。

bash-3.2$ java --version
openjdk 14.0.2 2020-07-14
OpenJDK Runtime Environment (build 14.0.2+12-46)
OpenJDK 64-Bit Server VM (build 14.0.2+12-46, mixed mode, sharing)

プロジェクトの pom.xml 以下の通り。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.4.0</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>demo</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>11</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.thymeleaf.extras</groupId>
            <artifactId>thymeleaf-extras-springsecurity5</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

CSRFトークンを使用してログイン

Spring Security は特に設定せずともCSRFトークンを用いてログインするように設定されています。

まずはコンフィグレーションでユーザの作成、エンドポイントの保護を行います。

package com.example.demo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public UserDetailsService userDetailsService() {
        InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager();
        UserDetails user = User.withUsername("mary")
                .password("12345")
                .authorities("READ")
                .build();
        inMemoryUserDetailsManager.createUser(user);
        return inMemoryUserDetailsManager;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }


    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .anyRequest().authenticated();
        
        http.formLogin()
                .defaultSuccessUrl("/product", true);
    }

}

アプリケーションを起動すると以下のようなログイン画面が返却されると思います。このフォームの中で hidden が設定されている _csrf パラメータがCSRFトークンになります。

<form class="form-signin" method="post" action="/login">
        <h2 class="form-signin-heading">Please sign in</h2>
        <p>
          <label for="username" class="sr-only">Username</label>
          <input type="text" id="username" name="username" class="form-control" placeholder="Username" required autofocus>
        </p>
        <p>
          <label for="password" class="sr-only">Password</label>
          <input type="password" id="password" name="password" class="form-control" placeholder="Password" required>
        </p>
        <input name="_csrf" type="hidden" value="f8c41886-abc9-4a34-aeb6-ae0b969367e2" />
        <button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button>
</form>

デフォルトのログインでは上記のHTMLで分かる通りGETリクエストによって取得した画面の中にhiddenパラメータとしてCSRFトークンが埋め込まれ、フォームを送信するときにWebアプリケーションに対してCSRFトークンを送信する動作になります。埋め込まれている値はWebアプリケーションがセッションごとに生成したUUIDの値です。

この hidden パラメータを使ったやり取りは独自にPOSTリクエストを送信するときも同じように使用できます。

CSRFトークンを使って独自のPOSTリクエストを処理する

独自に実装するPOSTリクエストのエンドポイントでCSRFトークンを使ってみようと思います。

Controllerクラスを実装してGETとPOSTリクエストを受け付けます。GETリクエストでは画面を単に返します。POSTリクエストでは name リクエストパラメータを受け取りログに出力します。POSTリクエストでもGETリクエストと同じ画面を返却します。

package com.example.demo.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;

import java.util.logging.Logger;

@Controller
@RequestMapping("/product")
public class ProductController {

    private final Logger logger = Logger.getLogger(ProductController.class.getName());

    @GetMapping
    public String product() {
        return "main";
    }

    @PostMapping("/add")
    public String add(@RequestParam String name) {
        logger.info("add product: " + name);
        return "main";
    }

}

main.html はこちら。Thymeleafを使っているので th タグを使いCSRFトークンを hidden パラメータとして埋め込んでいます。 _csrf という名前でCSRFトークンを保持するインスタンスにアクセスすることができるのですが、これはSpringSecurityが自動的に行なってくれています。開発者が独自に実装するのはどのような名前で、なんの値を送信するかを決めることなので、 _csrf.parameterName で送信する名前を、 _csrf.tokenトークン値を送信するようにします。

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
  <head>
    <meta charset="UTF-8">
    <title>Main</title>
  </head>
  <body>
    <form action="/product/add" method="post">
      <span>Name:</span>
      <span><input type="text" name="name"/></span>
      <span><button type="submit">Add</button></span>

      <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
    </form>
  </body>
</html>

Spring Security がCSRFトークンを処理する仕組み

Spring Security はCSRFトークンを独自のサーブレットフィルター CsrfFilter を用いて処理を行っています。CSRFフィルターはGETリクエストでCSRFトークンを生成しセッションに保存したり、POSTリクエストでCSRFトークンの検証をおこなったりするフィルターです。CSRFフィルターと関わりを持つ重要なクラスは CsrfTokenRepositoryCsrfToken です。

CsrfFilter - Javadoc

CsrfTokenRepository - Javadoc

CsrfToken - Javadoc

CsrfTokenRepositoryCSRFトークンの生成処理や保存などを担当します。これはインターフェイスなので独自のクラスに切り替えることでUUID以外の採番を行えたり、セッション以外にCSRFトークンを保存したりすることができます。 CsrfTokenCSRFトークン自体を表現するクラスです。

前述のサンプルで _csrf としてビューからCSRFトークンを参照できるようにしてくれているのは CsrfFilter のおかげというわけです。 CsrfFilter_csrf という名前で CsrfToken クラスをサーブレットリクエストの属性に追加します。

CsrfFilter の動きを簡単にまとめるとこんなかんじでしょうか。

GETリクエストのときはこう。

f:id:bau1537:20201123205003p:plain

  1. CsrfFilterCsrfTokenRepository を呼び出す。
  2. CsrfTokenRepositoryCsrfToken を生成してセッションに保存する。
  3. CsrfFilter は後続のフィルターチェーンを呼び出す。

POSTリクエストのときはこう。

f:id:bau1537:20201123205258p:plain

  1. CsrfFilterCsrfTokenRepository から CsrfToken を取り出し、送信されてきた値と比較する。
  2. 同じであれば後続のフィルターチェーンを呼び出す。同じでないのであれば 403 リクエストを返す。