本記事は
【Advent Calendar 2023】
11日目の記事です。
🎄
10日目
▶▶ 本記事 ▶▶
12日目
🎅
はじめに
こんにちは、去年の11月に中途入社した上村です。転職してから1年となり、時間が経つのが早いと感じます...
私はSpring Bootを用いたWebアプリケーションに業務で携わっています。その中で、CSRF(クロスサイトリクエストフォージェリ)という脆弱性に触れる機会がありました。Spring Securityの機能により、CSRF対策が簡単にできることを学んだので、紹介していきます。
CSRFとは
CSRFとは、Webアプリケーションの脆弱性の一つです。本来拒否すべきリクエストを受信して処理してしまう脆弱性、もしくはその脆弱性を突く攻撃を表します。
通販システムを例として説明します。この通販システム向けの攻撃用サイトを攻撃者が予め用意しておきます。正規のユーザーにその攻撃用サイトにアクセスさせることで、不正なリクエストが送信されます。通販システムにCSRFの脆弱性がある場合、その不正なリクエストを受けつけてしまいます。例えば、攻撃者が決めたパスワードへと変更させる不正なリクエストを送信させるとします。正規のユーザーが攻撃用サイトにアクセスしたあと、パスワードが意図せず変更され、攻撃者は変更後のパスワードでログイン可能となってしまいます。
対策
CSRFの対策方法はいくつか存在します。
- CSRFトークンの利用
- Refererヘッダーの検証
- など
最もよく使われると言われているのは、CSRFトークンの利用です。本記事では、CSRFトークンの利用について説明します。
対策の流れは以下の通りです。
- サーバ側でランダムな値を発行し、CSRFトークンとしてセッションに保存
- CSRFトークンをhidden形式でフォームパーツとしてHTMLに埋め込み、そのHTMLをブラウザに返す
- ブラウザからサーバにリクエストする際、フォームパーツのCSRFトークンも一緒に送信
- サーバがCSRFトークンの一致不一致を検証することで、正当なユーザーからのリクエストかどうかを判別
CSRFトークンはサーバとユーザーのみが持つ情報なので、攻撃用サイトは不正なリクエストにCSRFトークンを含めることができません。従って、CSRFトークン検証の段階でエラーとし、攻撃に失敗させることができます。
Spring SecurityによるCSRF対策
Spring Bootを用いたWebアプリケーションを開発している場合、Spring Securityを利用することでCSRF対策が可能です。下記がSpring Securityの公式サイトとなっていて、CSRF対策について説明されています。 spring.pleiades.io
Spring Security は、デフォルトで POST リクエストなどの安全でない HTTP メソッドに対する CSRF 攻撃から保護するため、追加のコードは必要ありません。
上記の通り、明示的にCSRF対策の設定をコードとして書く必要がないので、開発者が意識しなくても対策が可能となっています。
CSRF対策の処理内容
Webアプリケーションにブラウザからアクセスした際に、ブラウザに返されるHTMLのform内に<input type="hidden" name="_csrf" value="[Webアプリケーションのサーバ側で生成したCSRFトークン]">
が自動的に埋め込まれます。そして、ユーザーが情報を入力後にリクエストすると、その情報とCSRFトークンが一緒にサーバに送られます。送られたCSRFトークンをSpring Securityが検証し、正当なユーザーからのリクエストかどうかを判別します。
CSRF対策を行うWebアプリケーションを実際に作ってみた
具体的にどのようにCSRF対策がされるのかは、実際にWebアプリケーションを作って動かしてみるのが一番理解しやすいかと思います。ということで、作ってみました。
利用した各種ライブラリなど
- Java:17
- Spring Boot:3.0.1
- Spring Security:6.0.1
- Thymeleaf:3.1.1.RELEASE
用意したソースコード
- demoCsrf.html:一つのテキスト入力欄と送信ボタンが表示されるHTML。
- DemoCsrfController.java:エンドポイント「GET /demoCsrf」と「POST /demoCsrf」を持つコントローラー。どちらのエンドポイントも、リクエストされた際はdemoCsrf.htmlを返す処理のみを行う。
- MyWebSecurityConfig.java:認証のための設定を記載。本記事で作成したWebアプリケーションでは、ログイン周りの設計を簡単にするため、インメモリ認証を行う。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>DemoCsrf</title> </head> <body> <form th:action="@{/demoCsrf}" method="post"> <div>Text : <input type="text" name="text"/></div> <div><input type="submit" value="Send"/></div> </form> </body> </html>
package com.example.todoApplication.controller; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @Controller public class DemoCsrfController { @GetMapping("/demoCsrf") public String showDemoCsrfPage(){ return "demoCsrf"; } @PostMapping("/demoCsrf") public String postDemoCsrfPage(){ return "demoCsrf"; } }
package com.example.todoApplication.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 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.provisioning.InMemoryUserDetailsManager; @Configuration @EnableWebSecurity public class MyWebSecurityConfig { @Bean public UserDetailsService userDetailsService() { UserDetails user = User.withDefaultPasswordEncoder() // パスワードがこのソースコードから分かるため、withDefaultPasswordEncoderは実際の開発では使ってはいけない(https://spring.pleiades.io/spring-security/reference/servlet/authentication/passwords/in-memory.html) .username("user") // ユーザー名 .password("password") // パスワード .roles("USER") // ロール(ここでは適当な名前で良い。何らかの形でロールを持たせておかないと、エラーとなる。) .build(); return new InMemoryUserDetailsManager(user); } }
Webアプリケーションにアクセス
- ローカルでビルド後、ブラウザから
http://localhost:8080/demoCsrf
へアクセス - Spring Security側で用意されているログイン画面
http://localhost:8080/login
に遷移するので、ユーザー名user
とパスワードpassword
を入力してログイン http://localhost:8080/demoCsrf
に遷移
デベロッパーツールを開くと、以下の図の通りフォームに_csrf
が埋め込まれている事がわかります。Sendボタンが押下された時、この_csrf
も一緒にサーバへ送られ、検証されます。
CSRFトークンの検証をわざと失敗させるとどうなるか
開発しているとCSRFトークンについて意識する必要がないので、もしかしたら本当に検証されているか気になるかもしれません。では、わざとCSRFトークンにでたらめな値をブラウザ側から指定してリクエストするとどうなるのでしょうか。検証に失敗し、エラーがブラウザに返ってくるはずです。
下記の状態でSendボタンを押下します。
すると、以下の通り403エラーが返ります(想定通り!)。
コンソールも確認すると、以下の通り不正なCSRFトークンが見つかったというログが出力されています。
デバッガ(Intellij)でも挙動を追ってみましたが、CSRFトークンの検証処理はCsrfFilter#doFilterInternal
でされていそうです。
以下の条件分岐if (!equalsConstantTime(csrfToken.getToken(), actualToken))
により検証処理がされ、一致しなければAccessDeniedException
がAccessDeniedHandler
に渡され、処理が終了していそうです。
@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { DeferredCsrfToken deferredCsrfToken = this.tokenRepository.loadDeferredToken(request, response); ~略~ CsrfToken csrfToken = deferredCsrfToken.get(); String actualToken = this.requestHandler.resolveCsrfTokenValue(request, csrfToken); if (!equalsConstantTime(csrfToken.getToken(), actualToken)) { boolean missingToken = deferredCsrfToken.isGenerated(); 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); }
なお、CSRFトークンの検証シーケンスも公式サイト(https://spring.pleiades.io/spring-security/reference/servlet/exploits/csrf.html#csrf-components)に載っています。この検証シーケンス以外にもいろんな情報が載っているので、どんなことができるのかざっくりと一通り眺めてみたり、やりたいことがあればこの公式サイトから探してみたりするのも良さそうです。
終わりに
ここまでで、Spring SecurityによりCSRF対策が可能なことを説明しました。Spring Securityだけでなく、フレームワークの機能によりCSRF対策ができるケースも多いかと思います。CSRFは有名な脆弱性なので、独自に対策を実装するのではなくフレームワークの機能にのっとった上で対策するのが効率的かつ確実かと思います。
また、業務で開発したWebアプリケーションにはセキュリティの担保が必要なケースも多いので、リリース前に脆弱性診断を行う場合もあるかと思います。脆弱性診断でCSRFについて指摘される場合もあるので、しっかり対策していきたいです。