こんにちは、かつコーチです。
フォームログインを実装したあと、POSTリクエストを送るテストコードを書いたら403 Forbiddenが返ってきて困った、という経験はありませんか。
筆者も最初にこれに遭遇したとき、認証設定を疑って何時間も見当違いの調査をしてしまいました。
実はこれ、Spring Securityが標準で有効にしているCSRF対策(Cross-Site Request Forgery対策)が働いているだけのことが多いです。
この記事では、CSRFがどんな攻撃なのか、Spring Securityがデフォルトでどう防いでいるのか、そして403にハマったときの対処法を解説します。
CSRFとは何か
攻撃者が第三者になりすましてリクエストを送る仕組み
CSRF(Cross-Site Request Forgery、クロスサイトリクエストフォージェリ)とは、ログイン中のユーザーのブラウザを悪用し、本人の意図しないリクエストを勝手に送信させる攻撃です。
たとえば、ユーザーが銀行サイトにログインしたまま悪意あるサイトを開くと、そのページに仕込まれた自動送信フォームが、ログイン中のセッションCookieを使って送金リクエストを送ってしまう、といった被害が起こり得ます。
ブラウザはリクエスト先のドメインが違っていても、対象サイトのCookieを自動的に付与するため、サーバー側だけではリクエストが「本人の意思によるものか」を区別できません。
CSRFトークンによる防御の考え方
Spring Securityは、フォームやAJAXリクエストにCSRFトークン(サーバーが発行するワンタイムの検証用文字列)を含めることを要求することで、この攻撃を防いでいます。
トークンはセッションごとに発行され、悪意あるサイト側からは値を知りようがありません。
サーバーはリクエストを受け取った際に、送られてきたトークンとセッションに保存されているトークンを照合し、一致しなければリクエストを403で拒否します。
基本の書き方・実装手順
デフォルトでCSRF保護が有効になっている
Spring Securityは、SecurityFilterChainを明示的にカスタマイズしない限り、CSRF保護がデフォルトで有効です。
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/register").permitAll()
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults());
// csrf()を書かなくても保護は有効
return http.build();
}
}
GETのような安全なメソッドはトークン検証の対象外ですが、POST・PUT・DELETE・PATCHはすべてトークン検証の対象になります。
Thymeleafでトークンを自動送信する
Thymeleafのth:actionを使ったフォームであれば、CSRFトークンは自動的にhidden項目として埋め込まれます。
<form th:action="@{/orders}" method="post">
<input type="text" name="productName" />
<button type="submit">注文する</button>
</form>
生成されるHTMLを確認すると、次のようなinputタグが自動追加されているのが分かります。
<input type="hidden" name="_csrf" value="a1b2c3d4-...">
普通のHTMLの<form action="...">を使うと、この自動埋め込みが働かないため、th:actionを使う習慣をつけておくと事故を防げます。
つまずきやすい設定・注意点
RestControllerでAPIを組んでいる場合、フォームのようにトークンを自動で埋め込む仕組みがありません。
JavaScriptからfetchやaxiosでリクエストを送る場合は、レスポンスヘッダやCookieに含まれるトークンを自分で読み取り、リクエストヘッダに付与する必要があります。
const token = document.querySelector('meta[name="_csrf"]').content;
const header = document.querySelector('meta[name="_csrf_header"]').content;
fetch('/api/orders', {
method: 'POST',
headers: { [header]: token, 'Content-Type': 'application/json' },
body: JSON.stringify({ productName: 'サンプル' })
});
metaタグ側もThymeleafのテンプレートに仕込んでおく必要があるため、SPAとの連携ではこの一手間が抜けやすいポイントです。
よくあるつまずきポイント・エラー対処
❌ Before:とりあえずCSRFを無効化して回避する
筆者は開発初期、403の原因調査が面倒になり、次のようにCSRF保護そのものを無効化して急場をしのいだことがあります。
http.csrf(csrf -> csrf.disable());
このコードでエラーは消えますが、アプリケーション全体がCSRF攻撃に無防備な状態になってしまいます。
セッションベースの認証(フォームログインなど)を使っている限り、この対処は本番環境では避けるべきです。
✅ After:APIパスだけを対象から除外する、または適切にトークンを送る
RestControllerのエンドポイントがCookieベースのセッションを使わず、トークン認証(JWTなど)のみで守られている場合に限り、そのパスだけをCSRF保護の対象外にする方法が現実的です。
http.csrf(csrf -> csrf
.ignoringRequestMatchers("/api/**")
);
一方、セッション認証を使うAPIであれば、対象を除外するのではなく、前述のようにトークンをヘッダに正しく付与する実装に直すのが本来の対処です。
「なぜ403になるのか」を理解しないままdisable()で全体を無効化するのが、CSRF対策で最もよく見る失敗パターンです。
応用・一歩先の使い方
トークンをCookieで管理するCsrfTokenRepository
Spring Security 6系では、CookieCsrfTokenRepositoryを使うと、CSRFトークンをHTTPレスポンスのCookieとしてブラウザに保存できます。
http.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);
withHttpOnlyFalse()はJavaScriptからCookieの値を読み取れるようにする設定で、SPAからAPIを呼ぶ構成との相性がよく、Angularなどのフレームワークが標準で対応している方式です。
JWT認証との併用を整理する
以前の記事で扱ったJWT認証のように、リクエストヘッダのAuthorizationトークンだけで認証を完結させる構成では、Cookieベースのセッションを使わないため、CSRF攻撃自体が成立しません。
そのため、JWT認証を採用するSecurityFilterChainでは、そのパスに限りcsrf.disable()またはignoringRequestMatchers()で除外するのが一般的な設計判断になります。
「CSRF対策が必要かどうか」は、認証方式がCookieセッションに依存しているかどうかで判断するのが基本の考え方です。
まとめ
この記事のポイント
- CSRFはログイン中ユーザーのブラウザを悪用し、意図しないリクエストを送らせる攻撃
- Spring Securityはデフォルトで
POST等にCSRFトークンの検証を要求する - ThymeleafのCSRFトークンは
th:actionを使えば自動で埋め込まれる - API連携ではトークンをヘッダに手動で付与する必要がある
csrf.disable()は原因を理解しないまま安易に使わない- JWT認証などCookieセッションに依存しない構成では、対象パスをCSRF保護から除外するのが妥当
次に読むべき記事
セキュリティ設定の全体像を振り返りたい方は、「Spring Securityの基本を理解する」も合わせてご覧ください。
タグ: Spring Boot, 中級者向け, セキュリティ