Spring Securityを設定していて、POSTリクエストが突然403になった経験はありませんか。ログを見ても原因が分からず、とりあえず csrf().disable() を書いてしまった、という人は多いと思います。

でも、この設定が正しいかどうかはアプリの種類によって全然違います。REST APIなら無効化して問題ないですが、Thymeleafのフォームアプリでは有効化しておかないと本物のセキュリティリスクになります。この記事で、設定の根拠を整理しましょう。

CSRF攻撃の仕組み

CSRFはCross-Site Request Forgeryの略で、ログイン済みユーザーに意図しない操作をさせる攻撃です。

具体的なシナリオで考えると分かりやすいです。あなたが銀行サイト(bank.example.com)にログインした状態で、悪意のある別サイト(evil.example.com)を開いたとします。そのサイトには以下のHTMLが隠れています。

<form action="https://bank.example.com/transfer" method="POST">
  <input type="hidden" name="to" value="attacker-account">
  <input type="hidden" name="amount" value="100000">
</form>
<script>document.forms[0].submit();</script>

ページを開いた瞬間、フォームが自動送信されます。このとき ブラウザは bank.example.com のセッションCookieを自動的に付与 します。銀行サーバーからすると、正規のユーザーが送金をリクエストしたように見えてしまうわけです。攻撃が成立する根本原因は「ブラウザがリクエスト先ドメインのCookieを自動付与する」という仕様にあります。

REST API(JWT認証)でCSRFが不要な理由

JWTを使ったステートレスなREST APIでは、CSRFを無効化しても問題ありません。理由は3点あります。

JWTはAuthorizationヘッダーで送信します。 ブラウザはカスタムヘッダーを自動付与しないため、悪意のあるサイトのフォームからはトークンを送ることができません。

カスタムヘッダーはCORSのプリフライトで阻止されます。 Authorization ヘッダーを付けると、ブラウザはOPTIONSリクエストを事前に送ります。CORSが正しく設定されていれば、別ドメインからのリクエストはここで止まります。

ステートレスなAPIはセッションを持ちません。 CSRFはセッションCookieが自動送信されることが前提なので、セッションがなければ攻撃の条件が成立しません。

JWTの実装についてはこちらの記事で詳しく解説しています。

セッション+Cookie認証のフォームアプリではCSRFが必要

Thymeleafなどのサーバーサイドレンダリングアプリは、通常セッションIDをCookieで管理します。これはCSRF攻撃の条件がそのまま揃う状況です。

ログイン後にセッションCookieが発行されると、ブラウザは同じドメインへのリクエストにそのCookieを自動で付けます。悪意のあるサイトから <form> タグで送信しても、ブラウザがセッションCookieを付けてしまうため、サーバーは正規のリクエストと区別できません。

CSRFトークンはこの問題を解決します。サーバーがランダムなトークンをフォームに埋め込み、一致しないリクエストを拒否することで、悪意のあるサイトからのリクエストを弾けます。

403エラーの診断

まず、アプリのログを確認しましょう。InvalidCsrfTokenExceptionMissingCsrfTokenException が出ていれば、CSRFが原因です。

GETは通るがPOST/PUT/DELETEだけ403になる パターンがCSRFの典型症状です。詳細なログを確認したい場合はTRACEレベルを設定します。

# application.properties
logging.level.org.springframework.security=TRACE

CORSエラーと混同しやすいですが、CORSはブラウザが別オリジンへのリクエストをブロックする仕組みで、CSRFとは別の問題です。CORSの設定はこちらで解説しています。

Spring Security 6:REST API向けCSRF無効化

JWT認証のREST APIでは以下のように設定します。

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        // JWT認証のためセッション不使用・CSRF不要
        .csrf(csrf -> csrf.disable())
        .sessionManagement(session ->
            session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        )
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/public/**").permitAll()
            .anyRequest().authenticated()
        );
    return http.build();
}

Spring Security 5以前では http.csrf().disable() と書いていましたが、6ではLambda DSLに統一されました。コメントに「なぜ無効化しているか」を残しておくと、後から設定を見た人が意図を理解しやすくなります。SessionCreationPolicy.STATELESS と合わせて使うことで、セッションを作らないという意図もコードで表現できます。

Spring Security 6:ThymeleafフォームアプリでのCSRF有効化

Spring SecurityはデフォルトでCSRFが有効です。withDefaults() を使って意図を明示的にコードに残しましょう。

import static org.springframework.security.config.Customizer.withDefaults;

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .csrf(withDefaults()) // セッション認証のため有効化(デフォルト動作を明示)
        .formLogin(withDefaults())
        .authorizeHttpRequests(auth -> auth
            .anyRequest().authenticated()
        );
    return http.build();
}

Thymeleafでは th:action を使うと、_csrf のhiddenフィールドが 自動的に埋め込まれます

<!-- Thymeleafテンプレート -->
<form th:action="@{/profile/update}" method="post">
  <input type="text" name="username" />
  <button type="submit">更新</button>
</form>

ブラウザのデベロッパーツールで生成されたHTMLを確認すると、hiddenフィールドが挿入されているのが分かります。

<!-- 実際に出力されるHTML -->
<form action="/profile/update" method="post">
  <input type="hidden" name="_csrf" value="abc123...">
  <input type="text" name="username" />
  <button type="submit">更新</button>
</form>

Thymeleafを使わない素のHTMLフォームでは自動埋め込みが行われないため、手動での設定が必要です。Thymeleafの詳しい使い方はこちらをご参照ください。

AJAXリクエストでのCSRFトークン送信

フォームアプリでAJAXを使う場合は、CSRFトークンをヘッダーに付与する必要があります。metaタグにトークンを埋め込んでJavaScriptで取得する方法が定番です。

<meta name="_csrf" th:content="${_csrf.token}">
<meta name="_csrf_header" th:content="${_csrf.headerName}">
const token = document.querySelector('meta[name="_csrf"]').content;
const header = document.querySelector('meta[name="_csrf_header"]').content;

fetch('/api/data', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    [header]: token
  },
  body: JSON.stringify({ key: 'value' })
});

fetchやaxiosからCookieを自動参照させたい場合は CookieCsrfTokenRepository を使います。

http
    .csrf(csrf -> csrf
        .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
    );

withHttpOnlyFalse() にするとJavaScriptからCookieを読めるようになります。XSRF-TOKEN という名前でトークンが発行され、リクエスト時は X-XSRF-TOKEN ヘッダーとして送信することで認証されます。

SameSiteクッキーとCSRF緩和の関係

SameSite=Strict はクロスサイトリクエストでCookieを送らないため、CSRF攻撃を防ぐ効果があります。SameSite=Lax はGETナビゲーションは許可しますが、別サイトからのPOST送信ではCookieを付与しません。

ChromeなどモダンブラウザはデフォルトでLaxが適用されるようになっており、CSRF攻撃のリスクは以前より下がっています。ただし古いブラウザや一部環境では機能しないことがあるため、SameSiteはCSRFトークンの代替ではなく補完策 として捉えましょう。Spring Securityのトークン検証と組み合わせて使うのが正しい姿勢です。

まとめ

アプリの種類によって設定が変わります。

ケース設定
REST API + JWT/APIキーcsrf(csrf -> csrf.disable()) + STATELESS
Thymeleafフォーム + セッションcsrf(withDefaults()) + 自動トークン埋め込み
フォームアプリ + AJAXCookieCsrfTokenRepository.withHttpOnlyFalse()
REST API + フォームが混在requestMatcherでパス別に設定を分岐

セッション認証のフォームアプリで csrf().disable() を書いてしまうと、本物のセキュリティホールになります。403エラーが出たときは、まずログで原因を特定してから、自分のアプリに合った設定を選ぶようにしましょう。

Basic認証の設定はこちらOAuth2ソーシャルログインはこちらも合わせてご覧ください。