Spring Security を使ってCSRFトークンを使ってみたという話です。
CSRFとは
Cross Site Request Forgery (CSRF) とは、ログインした状態のユーザが悪意のあるリンク等をクリックすることで、意図しない操作をアプリケーションに送信する攻撃のことです。受け取ったアプリケーションはリクエストを正当なものか、不当なものか判断できないので意図しない操作を実行してしまうことになります。
CSRFについての解説は以下のサイトがわかりやすいと思います。
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攻撃の防ぎ方を簡単に説明するとこんな感じになります。
- クライアントがGETリクエストをWebアプリケーションに送信する。
- WebアプリケーションはCSRFトークンを生成して、HTTPセッションに保存する。
- WebアプリケーションはCSRFトークンをクライアントに返す。
- クライアントはPOSTリクエスト(他、状態を変更するリクエストでも同じ)に受け取ってあるCSRFを付けて送信する。
- 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フィルターと関わりを持つ重要なクラスは CsrfTokenRepository
と CsrfToken
です。
CsrfTokenRepository
はCSRFトークンの生成処理や保存などを担当します。これはインターフェイスなので独自のクラスに切り替えることでUUID以外の採番を行えたり、セッション以外にCSRFトークンを保存したりすることができます。 CsrfToken
はCSRFトークン自体を表現するクラスです。
前述のサンプルで _csrf
としてビューからCSRFトークンを参照できるようにしてくれているのは CsrfFilter
のおかげというわけです。 CsrfFilter
は _csrf
という名前で CsrfToken
クラスをサーブレットリクエストの属性に追加します。
CsrfFilter
の動きを簡単にまとめるとこんなかんじでしょうか。
GETリクエストのときはこう。
CsrfFilter
はCsrfTokenRepository
を呼び出す。CsrfTokenRepository
はCsrfToken
を生成してセッションに保存する。CsrfFilter
は後続のフィルターチェーンを呼び出す。
POSTリクエストのときはこう。
CsrfFilter
はCsrfTokenRepository
からCsrfToken
を取り出し、送信されてきた値と比較する。- 同じであれば後続のフィルターチェーンを呼び出す。同じでないのであれば 403 リクエストを返す。