1. はじめに
SpringBootとReactでアプリを作成する中で、GETは問題なく動くのにPOSTでなぜか403が返ってくる現象について、その原因や解決策について調べたものをまとめました。
1-1 前提
Javaのバージョン:21
SpringBootのバージョン:4.0.5
SpringSecurityのバージョン:7.0.4
1-2 APIの設定について
フロントエンドはReactを使用し、HTTP通信はaxiosを使用しています。
参考までに、APIの設定を載せておきます。
import axios from "axios";
const api = axios.create({
baseURL: "http://localhost:8080",
withCredentials: true,
withXSRFToken: true,
xsrfCookieName: "XSRF-TOKEN",
xsrfHeaderName: "X-XSRF-TOKEN",
});
export default api;
withCredentials
axiosでクロスオリジン(異なるオリジン)通信を行う際、Cookieなどの認証情報を一緒に送受信するかどうかの設定
デフォルトはfalseで、trueにすることでクロスオリジンでもブラウザがCookieのやり取りをしてくれる
withXSRFToken
XSRFトークンをいつ含めるかの設定
undefined(デフォルト)の場合は同一オリジンのリクエストのみトークンを含める
trueの場合はクロスオリジンのリクエストでも常にトークンを含める
xsrfCookieName: "XSRF-TOKEN"
XSRFトークンの値として使用するCookieの名前を指定する
axiosはここで設定した名前のCookieを読み取り、xsrfHeaderNameで指定したヘッダーに値をセットする
xsrfHeaderName: "X-XSRF-TOKEN"
XSRFトークンの値を送信するHTTPヘッダーの名前を指定する
なぜ上記の設定が必要か
フロントとバックが別オリジンで、Cookieベースの認証を使う場合は必須の設定になります。
SpringSecurityのようにセッション管理をCookieで行なっている構成では、ログイン後もリクエストに毎回セッションCookieを送信する必要があります。
しかし、withCredentialsをtrueにしていないとブラウザは別オリジン宛のリクエストにCookieを付与してくれません。
2. エラーの原因
SpringSecurityはログイン機能を持つアプリケーションに対して、デフォルトでCSRFが有効になっています。具体的には、POSTやDELETEなどの状態変更するHTTPメソッドに対してCSRFトークンの検証が行われます。
そのため、フロントエンドから送るPOSTリクエストなどに有効なCSRFトークンが含まれていない場合、SpringSecurity内部のCsrfFilterがトークンの検証に失敗し、AccessDeniedExceptionのサブクラスであるInvalidCsrfTokenException(またはMissingCsrfTokenException)を発生させます。
結果として、クライアントには403 Forbiddenが返されます。
ここで疑問なのは、
APIの設定を正しく行なっていたのに、なぜCSRFトークンが含まれていなかったのか?
その原因はSpringSecurity6でのある変更が原因でした。
2-1 SpringSecurity6以降での変更点
SpringSecurity6では、アプリケーションに影響を与える可能性のある変更点が主に2つあります。
・パフォーマンス向上のため、リクエストごとにセッションを読み込む必要がないよう、
CsrfTokenの読み込みがデフォルトで遅延されるようになりました。・
CsrfTokenは、BREACH攻撃からCSRFトークンを保護するため、デフォルトでリクエストごとにランダム性を含めるようになりました。
つまり
-
CsrfTokenの読み込みを遅延させるようになった - BREACH攻撃から保護するために
XorCsrfTokenRequestAttributeHandlerクラスがデフォルトとして追加されました
2-2 CsrfTokenの遅延読み込みとは
SpringSecurity5までは、CSRF保護が有効な場合、リクエストのたびに全てのHTTPメソッドでセッションからCsrfTokenを読み込んでいました。
その後に「このリクエストは実際にCSRF検証(トークンの一致確認)が必要か」をRequestMatcherで判定します。ここで、GETやOPTIONSのような安全なメソッドは対象外とされ、検証はスキップされます。
この「GETでは検証しないのに、毎回セッションを読みに行く」無駄をなくすため、SpringSecurity6以降では、
-
DeferredCsrfTokenを作りリクエスト属性をセット - このリクエストはCSRF検証が必要か判断し、必要なら
DeferredCsrfToken.get()が呼ばれ、実際にセッションから値を読み込んで検証する
という読み込みを遅延する変更が入りました。
DeferredCsrfToken(spring-security-docs 7.1.0API)
2-3 BREACHとは
HTTPの圧縮機能を用いて、HTTPS上の通信をみてCSRF tokenなどの秘密を推測することができるという脆弱性。
BREACH攻撃
前提
- HTTPSでは通常レスポンスを「圧縮 → 暗号化」の順で処理します
- 圧縮アルゴリズムは「1回のレスポンスに同じ文字列が2箇所以上存在する場合にサイズを圧縮する」という性質を持っています
攻撃方法
圧縮を先にかけているため、暗号化後の長さは実質的に圧縮後の長さを反映していることになります。
そして、暗号化された後のデータは、中身が見えなくても長さ(バイト数)はレスポンスの増減から攻撃者は推測できてしまいます。
つまり、攻撃者は総当たりでリクエストを送り、レスポンスの圧縮後のバイト数を観察するだけで正解の文字を絞り混みます。
2-4 XorCsrfTokenRequestAttributeHandler
XorCsrfTokenRequestAttributeHandlerはリクエスト属性_csrfに値をセットするとき、ランダムなバイト列を生成してトークンとXOR演算しクライアントに返します。
そしてリクエストで返ってきたときにもう一度XOR演算して元のトークンを取り出します。
| タイミング | 何をするか |
|---|---|
| .getToken()が呼ばれたとき(Thymeleafのレンダリング等) | 生のトークン と ランダム値 を XOR演算(^)してマスクされた値を返す(エンコード) |
| リクエストで返ってきたトークンを検証するとき | 送られてきた値 と ランダム値 を XOR演算して元に戻す(デコード)、その後セッション/Cookieの値と比較 |
XorCsrfTokenRequestAttributeHandler (spring-security-docs 7.1.0 API
2-5 なぜCSRFトークンが有効でなかったのか
直接の原因はXorCsrfTokenRequestAttributeHandlerでトークン検証した際の、リクエストのトークンとCookieのトークンの不一致でした。
SpringSecurity6のデフォルトであるXorCsrfTokenRequestAttributeHandlerは、Cookieに保存された値がXORエンコードされている前提になっており、それをデコードしてから検証する仕様になっています。
トークンを生成してCookieに書き込むところから、リクエストで返ってきて検証される流れは以下のようになっています。
【1回目のリクエスト】
-
CookieCsrfTokenRepository.generateToken()で本物のトークンを生成 -
CookieCsrfTokenRepository.saveToken()が呼ばれ、XOR演算されていないそのままのトークンがCookieに書き込まれる -
XorCsrfTokenRequestAttributeHandler.handle()が呼ばれ、リクエスト属性_csrfにCsrfTokenオブジェクトをセットする
*SPAでは基本的に.getToken()を呼ばないのでXOR演算されず - ブラウザがCookieを保存する
【SPA側】
- axios等がCookieからトークンを読み取る
- 次のPOSTリクエストのヘッダーにそのまま詰めて送信する
【2回目のリクエスト】
-
XorCsrfTokenRequestAttributeHandler.resolveCsrfTokenValue()が呼ばれる
→ ヘッダーのトークンは「XORされているはず」という前提でデコードを実施
→ XORされていない生のトークンをデコードしてしまうため、結果が壊れる -
CookieCsrfTokenRepository.loadToken()でCookieから本物のトークンを読み込む - 壊れたデコード結果とCookieから取り出した本物の値を比較 → 不一致のため403が返る
_csrfというリクエスト属性は、あくまでそのリクエスト処理中にサーバー内の誰かが.getToken()を呼び出して初めて意味を持つ仕掛けです。
Thymleafの場合は${_csrf.token}という式が実際に.getToken()を飛び出すので、そこでXOR演算が発動します。
しかしSPA + JSON APIの場合サーバー側がHTMLをレンダリングしません。誰も.getToken()を呼ばないので、XORでラップされたトークンはそもそも生成されず、クライアントに渡ることもありません。
CookieCsrfTokenRepository (spring-security-docs 7.1.0 API)
3. 解決方法
解決策は簡単です。
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// ...
.csrf((csrf) -> csrf.spa());
return http.build();
}
}
.spa()のこの1行を追加するだけです。
これを呼ぶと、自動的に以下の2つが設定されます。
csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())
-
CookieCsrfTokenRepository.withHttpOnlyFalse():トークンをCookieに保存し、JavaScriptから読み取れるようにする -
SpaCsrfTokenRequestHandler:ヘッダー経由で来たトークンはXORデコードをスキップし、フォームのパラメータ経由で来た場合は通常通りXORデコードする
つまり、【2回目のリクエスト】で行われていたXorCsrfTokenRequestAttributeHandler.resolveCsrfTokenValue()が呼ばれてヘッダーのトークンをXORデーコードするという処理がスキップされ、そのままのトークンとCookieに保存された本物のトークンが比較されるので、エラーにならなくなります。
Cross Site Request Forgery (CSRF) spa
Configuring CSRF for Single Page Application
4. まとめ
CookieCsrfTokenRepositoryを使うSPA構成では、Cookieに書き込まれる値は生のトークン、デフォルトのXorCsrfTokenRequestAttributeHandlerはトークンがXORエンコードされている前提でデコードするという2つの仕様が噛み合わず、意図せず403エラーが発生しました。
これは設定や実装ミスというより、SpringSecurity6以降の変更が原因でした。
しかし、SpringSecurity7.0で追加された csrf.spa() を使うことで簡単に解決できます。
SPA構成でCSRF関連の403エラーに遭遇した場合は、まず自分が使っているSpringSecurityのバージョンを確認し、7.0以降であれば csrf.spa() を、それより前のバージョンであれば公式リファレンスに掲載されているカスタムハンドラーのサンプルコードを導入することでエラーが解決するかもしれません。