はじめに
Spring SecurityでCSRF(クロスサイトリクエストフォージェリ)対策を容易に行えます。
しかし、シングルページアプリケーションやRestAPIの場合、Webページに直接トークンを付与することが行えません。
そこで、Spring Securityでは、CookieCsrfTokenRepositoryクラスが用意されており、これを用いて、トークンをCookieに出力し、受け取ったクライアント側アプリがCookieの"XSRF-TOKEN"からトークン値を読み取り、そして読み取ったトークン値をリクエストヘッダ"X-XSRF-TOKEN"に設定してサーバにPOSTすることで、CSRF対策を行えます。
しかし、デフォルトの設定だと、この処理がうまくいかず、Cookieの"XSRF-TOKEN"の値をリクエストヘッダ"X-XSRF-TOKEN"に設定しても、HTTPステータス403エラーとなってしまいます。
これには、かなりハマってしまいました。
原因
デフォルト設定だと、CSRFのトークンチェックにXorCsrfTokenRequestAttributeHandlerが使用されています。
XorCsrfTokenRequestAttributeHandlerでは、トークン値はエンコードされた値をサーバに送信する必要があるのです。
しかし、Cookieに設定されている値はエンコードされている値ではありません。
そのため、Cookieに設定された値をヘッダに付与しても、値が不一致と認識されてしまい、403エラーとなってしまいます。
そのことについては、実は、公式サイトに書いてあります。
https://spring.pleiades.io/spring-security/reference/servlet/exploits/csrf.html#csrf-integration-javascript-spa
解決策
解決策は2つあります。
解決策1 CsrfTokenRequestAttributeHandler を使用する
上述のXorCsrfTokenRequestAttributeHandler はトークン値をエンコードしているので、それを行わない、CsrfTokenRequestAttributeHandler を使用すれば、とりあえず解決します。
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// ...
http
// ...
.csrf((csrf) -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()) // csrfTokenRequestHandlerにCsrfTokenRequestAttributeHandlerを設定
);
// ...
return http.build();
}
}
しかし、CsrfTokenRequestAttributeHandler の場合、トークン値はセッションが有効な間変わりません。リクエストごとにトークン値を変えたい場合は別の方法をとる必要があります。
解決策2 CsrfTokenRequestHandlerを継承してカスタマイズしたHandlerを作成する
これについては、上述の公式サイトにコードがそのまま書いてあります。
https://spring.pleiades.io/spring-security/reference/servlet/exploits/csrf.html#csrf-integration-javascript-spa
公式サイトより一部引用します。
final class SpaCsrfTokenRequestHandler extends CsrfTokenRequestAttributeHandler {
private final CsrfTokenRequestHandler delegate = new XorCsrfTokenRequestAttributeHandler();
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, Supplier<CsrfToken> csrfToken) {
/*
* Always use XorCsrfTokenRequestAttributeHandler to provide BREACH protection of
* the CsrfToken when it is rendered in the response body.
*/
this.delegate.handle(request, response, csrfToken);
}
@Override
public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
/*
* If the request contains a request header, use CsrfTokenRequestAttributeHandler
* to resolve the CsrfToken. This applies when a single-page application includes
* the header value automatically, which was obtained via a cookie containing the
* raw CsrfToken.
*/
if (StringUtils.hasText(request.getHeader(csrfToken.getHeaderName()))) {
return super.resolveCsrfTokenValue(request, csrfToken);
}
/*
* In all other cases (e.g. if the request contains a request parameter), use
* XorCsrfTokenRequestAttributeHandler to resolve the CsrfToken. This applies
* when a server-side rendered form includes the _csrf request parameter as a
* hidden input.
*/
return this.delegate.resolveCsrfTokenValue(request, csrfToken);
}
}
そして、上述のカスタマイズしたHandlerクラスをSecurityConfigに設定します。
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// ...
.csrf((csrf) -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()) // csrfTokenRequestHandlerに作成したカスタムHandlerを設定
);
return http.build();
}
}
これで、リクエストごとにトークン値が変更され、かつ、トークン値がエンコードされなくなるので、403エラーが出なくなります。
最後に
Spring SecurityではCSRF対策を簡単に行える仕組みが用意されているものの、シングルページアプリケーションやRestAPIの場合、デフォルト設定だとうまくいかないという事態が発生します。
実は公式サイトに書いてはいるのですが、備忘録としてここに記述しました。
困っている方の参考になれば幸いです。