Spring Boot REST API に対してCSRF対策を有効にした場合の使い方を調べてみた。さらにCSRF対策を有効にした状態で単体テストを行うためのテスコードについても記録しておく。
ちなみにCSRFおよびその対策について概要は理解しているつもりだが、詳細な点までちゃんと理解できているか不安なので、ここで行なっている設定が、セキュリティ的な視点で見た時に脆弱性を持っている可能性はある。これは個人的にもしっかり勉強していきたい。
Spring SecurityでCSRF対策を有効にする
Spring Securityを利用することで、CSRF対策を設定することができる。
クライアント側がリクエストを送信する際に、アプリケーション側で発行するランダムなcsrfトークンを含めることで、正規のリクエストであることを確認することができる。
下記のサンプルコードは、Cookieを利用したcsrf対策(CookieCsrfTokenRepository
)を行うように設定を変更したもの。※Spring Security 5.7に対応しています。
デフォルトではサーバー側のセッション情報を利用した対策方法(HttpSessionCsrfTokenRepository
)になる。
package com.demo.manager.presentation.config
import com.demo.manager.domain.enum.RoleType
import com.demo.manager.presentation.handler.SampleManagerAccessDeniedHandler
import com.demo.manager.presentation.handler.SampleManagerAuthenticationEntryPoint
import com.demo.manager.presentation.handler.SampleManagerAuthenticationFailureHandler
import com.demo.manager.presentation.handler.SampleManagerAuthenticationSuccessHandler
import org.springframework.context.annotation.Bean
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.csrf.CookieCsrfTokenRepository
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.CorsConfigurationSource
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
@EnableWebSecurity
class SecurityConfig {
@Bean
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http.authorizeHttpRequests { authz ->
authz.mvcMatchers("/admin/**").hasAuthority(RoleType.ADMIN.toString())
.anyRequest().authenticated()
}.formLogin { login ->
login.loginProcessingUrl("/login").permitAll()
.usernameParameter("email")
.passwordParameter("pass")
.successHandler(SampleManagerAuthenticationSuccessHandler())
.failureHandler(SampleManagerAuthenticationFailureHandler())
}.exceptionHandling { ex ->
ex.authenticationEntryPoint(SampleManagerAuthenticationEntryPoint())
.accessDeniedHandler(SampleManagerAccessDeniedHandler())
}.csrf { csrf ->
// Cookieでcsrfトークンを管理する方法採用する。また/loginリクエストはcsrf対策の適用外とする
csrf.ignoringAntMatchers("/login")
.csrfTokenRepository(CookieCsrfTokenRepository())
}.cors { cors ->
cors.configurationSource(corsConfigurationSource())
}
return http.build()
}
private fun corsConfigurationSource(): CorsConfigurationSource {
val corsConfiguration = CorsConfiguration()
corsConfiguration.addAllowedMethod(CorsConfiguration.ALL)
corsConfiguration.addAllowedHeader(CorsConfiguration.ALL)
corsConfiguration.addAllowedOrigin("http://localhost:8081")
corsConfiguration.allowCredentials = true
val corsConfigurationSource = UrlBasedCorsConfigurationSource()
corsConfigurationSource.registerCorsConfiguration("/**", corsConfiguration)
return corsConfigurationSource
}
}
サーバー側で生成したcsrfトークンを利用する方法
クライアント側ではサーバー側のアプリケーション(Spring Security)で生成したcsrfトークンを予め取得し、リクエストに含めることで、サーバー側がcsrfトークンを参照して適切な手順でリクエストされたものかを検証することができる。
Spring Securityでcsrfトークンを管理する方法は大きく2種類あり、csrfTokenRepository()
メソッドで指定する。
なお、生成した_csrfトークンはセッションごとに作られ、同一セッション中は同じ値となる。今回のようなアプリケーションだと、ログイン認証時に生成されログアウトやセッションが無効になるまで同じ値を利用する。
※リクエストごとにcsrfトークンを更新する方法もあるらしいが未確認である。
-> csrfTokenRequestHandler に XorCsrfTokenRequestAttributeHandler
オブジェクトを指定すれば、Request Attributeの _csrf
から Csrfインスタンスを取り出して値を取得すれば変わる模様。 XorCsrfTokenRequestAttributeHandler は Spring Security6 ではデフォルト値となっている。
このため ThymeleafなどのサーバーサイドでHTMLを生成する仕組みであれば POSTフォームなどがあるHTMLなら自動でトークンを取得したときに新しい値に更新されたものがセットされるはず(未確認)
SPAのようなAPI経由で取得する場合は API側で明示的にCsrfトークンを再取得するか、csrfトークン取得用のAPIを用意してクライアント側でPost送信前に再取得する必要がある。
HttpSessionCsrfTokenRepository
サーバー側は生成したcsrfトークンをセッション情報に格納し、クライアントへは、レスポンスのHTMLのformに_csrf
パラメータを追加し値にcsrfトークンをセットする。
サーバーサイドでThymeleafやJSPなどのテンプレートファイルからHTMLを生成して返す仕組みであれば、セッションで保持しているトークンをセットした状態でクライアントに以下のようなinputフォームをレスポンスを埋め込むと、name属性 "_csrf"でcsrfトークンの値がセットされる。
<input type="hidden" name="${_csfr.parameterName}" value="${_csrf.token}">
AjaxなどリクエストボディがformではなくJSON形式だと、前述の方法ではcsrfトークンが送信されないので、以下のようにHTTPヘッダーにセットする。
<meta name="_csrf" content="${_csrf.token}"/>
<!-- "_csrf.headerName" の デフォルト値は ”X-CSRF-TOKEN" がセットされる -->
<meta name="_csrf_header" content="${_csrf.headerName}"/>
クライアントこのHTMLからリクエストをSubmitすると、サーバー側は"_csrf"パラメータからcsrfトークンを取り出し、セッション情報のcsrfトークンと比較することでCSRF対策が行える。
SpringSecurityの設定はこちらがデフォルトとなる。
CookieCsrfTokenRepository
クライアント側のCookieにcsrfトークンをセットして検証する方法。サーバーからのレスポンでは、csrfトークンをセットしたCookie[XSRF-TOKEN]をクライアントにセットする。
クライアントは次回のリクエスト送信の際に、Cookieからcsrfトークンを取り出し、リクエストヘッダ[X-XSRF-TOKEN]にもセットしておく。
サーバー側はクライアントのリクエストから取得したヘッダー[X-XSRF-TOKEN]の値とCookie[XSRF-TOKEN]の値が一致すること検証することでCSRF対策が行える。
REST APIなどレスポンスにHTMLが無くcsrfトークンが埋め込めない場合などに利用できる。またサーバー側のセッション情報にcsrfトークンを格納しないため、セッションタイムアウトなどの影響がなくなり、ステートレスなアプリケーションが実現できる。
前述のコードの設定はこちらを採用している。
CSRF対策を考慮したControllerクラスの単体テストコードの書き方
csrf対策が有効な場合、"with(csrf())"を付けて、リクエストを呼び出さないと403エラーとなる。
今回は"CookieCsrfTokenRepository”を採用しており、csrfトークンをHTTPヘッダーに埋め込んでいるので、.with(csrf().asHeader())
としている
// package と import は省略
@WebMvcTest(controllers = [DemoController::class])
@WithCustomMockUser
@Import(CustomTestConfiguration::class)
internal class DemoControllerTest(@Autowired val mockMvc: MockMvc, @Autowired val jsonConverter: CustomJsonConverter) {
// 変数宣言は省略
@Test
fun `getList is success`() {
// Givenは省略
// When
val resultResponse =
mockMvc
.perform(
get("/demo/list")
.with(csrf().asHeader())
)
.andExpect(status().isOk)
.andReturn().response
// Thenは省略
}
}
単体テストを実行するためのポイント
Controllerクラスの単体テストを行う場合、MockMvcオブジェクトは次のように standaloneSetupで立ち上げることができる。
val mockMvc = MockMvcBuilders.standaloneSetup(CustomeController()).build()
ただし、今回のCSRF対策のようなSpring Securityの機能を利用している場合、認証情報やセッション情報などが必要なため、StandaloneSetupでは対応できなかった。
また @SpringBootTest
アノテーションを付けて結合テスト相当の環境を用意すればテストはできるが、DBやRedisなどが必要になってしまう。
そこで、サンプルコードのような書き方にして、DBやRedisと接続せずに、CSRF対策を含む、Spring Securityの機能も考慮した、Controllerの単体テストを行えるようにした。 ポイントは次の通り。
@WebMvcTest
アノテーションを付ける
このアノテーションを付けただけだと、全ての@ControllerクラスがDI対象になるため、"controllers"属性を使って、テスト対象のControllerクラスだけをDIするようにしてする。
MockMvcはコンストラクタインジェクションで定義する
MockMvcBuilders.standaloneSetup()
を使って生成するのではなく、 @Autowired
を付けてDIで定義する。
テスト対象となるControllerクラスが依存しているBeanを@MockBean
で指定する
Controllerクラスが依存しているServiceクラスとAuthenticationServiceクラスを指定する。
前述の @WebMvcTest
の "controllers"属性でControllerクラスを指定しないと全てControllerクラスが依存しているBeanを定義することになるので気をつける。
AuthenticationService を @MockBean
で定義している理由
まず、Spring Securityを利用している場合は、Controllerクラスのテストでは、認証・認可等のチェックのため、SecurityConfig
クラスを参照するため、AuthenticationServiceに依存している。
さらに今回のサンプルアプリでは、AuthenticationServiceの実装クラスが、Repositoryクラスに依存しているので、つまりDB接続が必要になるので、これを回避するためにAuthenticationServiceをMockにしている。
AuthenticationServiceの実装クラスが DB接続を行わない、あるいはテストでもDB接続させるのであれば、下記のように@WebMVcTest
アノテーションの"IncludeFilters"属性で定義することもできる。
@WebMvcTest(
controllers = [CustomeController::class],
includeFilters = [ComponentScan.Filter(
type = FilterType.ASSIGNABLE_TYPE,
classes = [AuthenticationService::class, AccountRepository::class]
)]
)
internal class CustomeControllerTest(@Autowired private val mockMvc: MockMvc) {
}
実際にCSRF対策を動作しているかを確認する
サンプルアプリケーションは 次のような仕組みにしている。
- ログイン認証リクエストはcsrfトークンの検証を不要にし、csrfトークンが無くてもアクセスできるようにする
- ログイン認証が成功するとサーバーcsrfトークンを生成し、クライアントのCookie[XSRF-TOKEN]にセットする
- "HttpSessionCsrfTokenRepository"の場合なら、"_csrf.token"をレスポンスボディのHTMLに埋め込む
- ログイン認証以降は、"GETメソッド以外"のリクエスト送信の際に、リクエストヘッダに[X-XSRF-TOKEN]をセットし、Cookie[XSRF-TOKEN]の値をセットする。
これをPostmanを用いて確認してみる。
①まず、EnvironmentメニューからPostmanの環境変数を[xsrf_token]として新規作成する。
②ログイン認証を実行するAPI[POST:/login] の "Tests"タブで、作成した[xsrf_token]にCookie[XSRF-TOKEN]の値をセットし送信する。
pm
はPostmanインスタンスのこと。補完機能があるので、変数名なども選択できる。
③ログイン認証が通ると②の設定によって、環境変数[xsrf_token]に csrfトークンがセットされる。
④ログイン認証リクエストおよび GETメソッド以外のAPIリクエストでは"Headersタブ"で、リクエストヘッダ[X-XSRF-TOKEN]を追加し環境変数[xsrf_token]の値{{xsrf_token}}
をセットする。
⑤csrfトークンをセットしたAPIを送信すると403エラーとならずに正常にリクエストが処理される。
⑥わざとリクエストヘッダ[X-XSRF-TOKEN]の値を書き換えたり、ヘッダ自体を無効にすると、403Forbidenエラーが返ることも確認する。
参考リンク