Spring Securityは、CSRF(Cross-Site Request Forgery)攻撃に対するデフォルトの保護機能を提供しています。しかし、SPA(Single Page Application)やREST APIなど、アプリケーションの形態に応じた適切な設定が必要です。
この記事では、CSRF攻撃の原理を確認したうえで、Spring SecurityのCsrfFilterの仕組み、トークンの生成・検証プロセス、SPA向けのCookieCsrfTokenRepository設定、REST APIでCSRF保護を無効化する際の判断基準について解説します。
実行環境#
本記事のコード例は以下の環境で動作を確認しています。
- Java 21
- Spring Boot 3.4.x
- Spring Security 6.4.x
CSRF攻撃の原理#
CSRF攻撃は、ユーザーが認証済みのWebアプリケーションに対して、悪意のあるリクエストを強制的に送信させる攻撃手法です。
攻撃が成立する仕組み#
sequenceDiagram
participant User as ユーザー
participant App as Webアプリケーション
participant Evil as 攻撃者サイト
User->>App: 1. ログイン
App-->>User: セッションCookie発行
Note over User,Evil: ログイン状態のまま攻撃者サイトを訪問
User->>Evil: 2. 攻撃者サイトにアクセス
Evil-->>User: 不正なフォームを含むHTML
Note over User,App: ブラウザがCookieを自動送信
User->>App: 3. 不正リクエスト + Cookie
App-->>User: リクエスト処理(被害発生)攻撃者は、ユーザーのブラウザが自動的にセッションCookieを送信する仕組みを悪用します。サーバーはCookieのみでリクエストの正当性を判断するため、攻撃者が構築した不正なリクエストも正規のリクエストとして処理されてしまいます。
CSRF攻撃が成立する3つの条件#
CSRF攻撃が成功するには、以下の3条件がすべて揃う必要があります。
| 条件 |
説明 |
| 状態変更操作の存在 |
送金、パスワード変更、設定更新など、サーバー側の状態を変更するエンドポイントが存在する |
| Cookieベースの認証 |
サーバーがリクエストの認証をセッションCookieのみで行っている |
| パラメータの予測可能性 |
攻撃者がリクエストに必要なすべてのパラメータを予測できる |
3つ目の条件を満たさないようにするのが、CSRFトークンによる防御の基本的な考え方です。
Spring SecurityにおけるCSRF保護の仕組み#
Spring Security 6以降では、CSRF保護はデフォルトで有効化されています。
CsrfFilterの処理フロー#
CsrfFilterは、Spring Security Filter Chainの一部として動作し、CSRF攻撃からアプリケーションを保護します。
flowchart TB
A[HTTPリクエスト受信] --> B{HTTPメソッドの判定}
B -->|GET, HEAD, TRACE, OPTIONS| C[安全なメソッド<br>CSRF検証スキップ]
B -->|POST, PUT, DELETE, PATCH| D[状態変更メソッド<br>CSRF検証が必要]
D --> E{CSRFトークンの検証}
E -->|トークン有効| F[検証成功]
E -->|トークン無効/なし| G[AccessDeniedException]
C --> H[次のFilterへ]
F --> H
G --> I[403 Forbidden]CsrfFilterの主要な処理ステップは以下のとおりです。
DeferredCsrfTokenをロードし、永続化されたCsrfTokenへの参照を保持する
CsrfTokenRequestHandlerにSupplier<CsrfToken>を渡し、リクエスト属性としてCsrfTokenを利用可能にする
- HTTPメソッドがGET、HEAD、TRACE、OPTIONSの場合はCSRF検証をスキップする
- 検証が必要な場合、永続化された
CsrfTokenをロードする
- クライアントから送信された実際のCSRFトークンを解決する
- 永続化されたトークンと実際のトークンを比較し、一致すれば処理を継続する
- 不一致または欠落の場合、
AccessDeniedExceptionをスローする
CsrfTokenの構成要素#
CsrfTokenインターフェースは、以下の3つの情報を持ちます。
1
2
3
4
5
6
7
8
9
10
|
public interface CsrfToken extends Serializable {
// HTTPリクエストヘッダー名(デフォルト: X-CSRF-TOKEN)
String getHeaderName();
// リクエストパラメータ名(デフォルト: _csrf)
String getParameterName();
// 実際のトークン値
String getToken();
}
|
デフォルト設定の確認#
Spring Security 6では、以下の設定がデフォルトで適用されます。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// CSRF保護はデフォルトで有効
.csrf(Customizer.withDefaults());
return http.build();
}
}
|
デフォルトではHttpSessionCsrfTokenRepositoryが使用され、CSRFトークンはHttpSessionに保存されます。
CSRFトークンの永続化方式#
Spring Securityは、CSRFトークンを永続化するための2つの主要なリポジトリを提供しています。
HttpSessionCsrfTokenRepository#
デフォルトで使用されるリポジトリで、CSRFトークンをHttpSessionに保存します。
1
2
3
4
5
6
7
8
9
|
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(new HttpSessionCsrfTokenRepository())
);
return http.build();
}
|
このリポジトリの特徴は以下のとおりです。
| 項目 |
説明 |
| 保存場所 |
HttpSession(メモリ、キャッシュ、またはデータベース) |
| 適用シーン |
サーバーサイドレンダリング(SSR)のWebアプリケーション |
| セキュリティ |
トークンがクライアント側に露出しない |
CookieCsrfTokenRepository#
JavaScriptアプリケーション向けに、CSRFトークンをCookieに保存するリポジトリです。
1
2
3
4
5
6
7
8
9
|
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);
return http.build();
}
|
withHttpOnlyFalse()を使用する理由は、JavaScriptからCookieを読み取れるようにするためです。
| 項目 |
説明 |
| Cookieの名前 |
XSRF-TOKEN(デフォルト) |
| リクエストヘッダー名 |
X-XSRF-TOKEN(デフォルト) |
| リクエストパラメータ名 |
_csrf(デフォルト) |
SPA向けのCSRF設定#
Spring Security 6では、SPA向けの簡潔な設定方法が提供されています。
spa()メソッドによる設定#
Spring Security 6.4以降では、spa()メソッドを使用した簡潔な設定が可能です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
@Configuration
@EnableWebSecurity
public class SpaSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.spa())
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
}
|
この設定により、以下の処理が自動的に行われます。
CookieCsrfTokenRepositoryの使用
- BREACH攻撃対策のためのトークンエンコーディング
- 認証成功・ログアウト成功時の新しいトークン発行
従来の方式によるSPA設定#
spa()メソッドを使用しない場合、以下のように手動で設定します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
@Configuration
@EnableWebSecurity
public class SpaSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())
)
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()
);
return http.build();
}
}
final class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler {
private final CsrfTokenRequestHandler plain = new CsrfTokenRequestAttributeHandler();
private final CsrfTokenRequestHandler xor = new XorCsrfTokenRequestAttributeHandler();
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
Supplier<CsrfToken> csrfToken) {
// CsrfTokenをリクエスト属性として利用可能にする
xor.handle(request, response, csrfToken);
// CSRFトークンを読み込み、Cookieを生成する
csrfToken.get();
}
@Override
public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
String headerValue = request.getHeader(csrfToken.getHeaderName());
// Cookieからの値はプレーンテキストとして解決
// フォームからの値はBREACH保護のためXORで解決
return (StringUtils.hasText(headerValue) ? plain : xor)
.resolveCsrfTokenValue(request, csrfToken);
}
}
|
SPAからのCSRFトークン送信#
フロントエンドのSPAからは、Cookieに保存されたCSRFトークンを読み取り、リクエストヘッダーに含めて送信します。
JavaScript(fetchを使用した例):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
// Cookieからトークンを取得するユーティリティ関数
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) {
return parts.pop().split(';').shift();
}
return null;
}
// APIリクエストの送信
async function postData(url, data) {
const csrfToken = getCookie('XSRF-TOKEN');
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': csrfToken // CSRFトークンをヘッダーに含める
},
credentials: 'include', // Cookieを送信
body: JSON.stringify(data)
});
return response.json();
}
|
Angularを使用している場合、HttpClientモジュールが自動的にXSRF-TOKENをCookieから読み取り、X-XSRF-TOKENヘッダーとして送信します。
BREACH攻撃対策#
Spring Security 6では、BREACH攻撃に対する保護がデフォルトで有効化されています。
BREACH攻撃とは#
BREACH(Browser Reconnaissance and Exfiltration via Adaptive Compression of Hypertext)攻撃は、HTTPレスポンスの圧縮を利用して、CSRFトークンなどの秘密情報を推測する攻撃手法です。
XorCsrfTokenRequestAttributeHandler#
デフォルトで使用されるXorCsrfTokenRequestAttributeHandlerは、リクエストごとにトークンにランダム性を加えることでBREACH攻撃を防ぎます。
flowchart LR
A[元のCSRFトークン] --> B[ランダム値でXOR演算]
B --> C[エンコードされたトークン]
C --> D[クライアントに送信]
E[エンコードされたトークン受信] --> F[XOR演算でデコード]
F --> G[元のトークンと比較]トークンの生成と検証の流れは以下のとおりです。
- リクエストごとに新しいランダム値を生成
- 元のCSRFトークンとランダム値をXOR演算
- エンコードされたトークンをクライアントに返却
- クライアントから受け取ったトークンをデコードして検証
BREACH保護を無効化する場合#
特定のケースでBREACH保護が不要な場合は、CsrfTokenRequestAttributeHandlerを使用します。
1
2
3
4
5
6
7
8
9
|
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
);
return http.build();
}
|
ただし、セキュリティ上の理由から、特別な要件がない限りデフォルトのBREACH保護を維持することを推奨します。
REST APIでのCSRF無効化の判断基準#
REST APIを構築する場合、CSRF保護を無効化するかどうかは、アプリケーションの認証方式と利用形態によって判断します。
CSRF保護を無効化できる条件#
以下の条件をすべて満たす場合、CSRF保護を無効化しても安全です。
flowchart TD
A[REST APIのCSRF設定判断] --> B{ブラウザからアクセスするか?}
B -->|いいえ| C[CSRF無効化可能]
B -->|はい| D{認証方式は?}
D -->|Bearer Token/JWT| E{Cookieにトークンを保存するか?}
D -->|Cookie/Session| F[CSRF有効化が必要]
E -->|LocalStorage/Memory| G[CSRF無効化可能]
E -->|Cookie| F
| 条件 |
説明 |
| 非ブラウザクライアントのみ |
モバイルアプリやサーバー間通信など、ブラウザを経由しないクライアントのみが利用 |
| Bearerトークン認証 |
JWTなどのトークンをAuthorizationヘッダーで送信し、Cookieを使用しない |
| ステートレス |
セッションを使用せず、リクエストごとに認証情報を送信 |
CSRF無効化の設定#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
@Configuration
@EnableWebSecurity
public class ApiSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults())
);
return http.build();
}
}
|
部分的なCSRF無効化#
特定のエンドポイントのみCSRF保護を除外することも可能です。
1
2
3
4
5
6
7
8
9
|
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.ignoringRequestMatchers("/api/webhook/**", "/api/public/**")
);
return http.build();
}
|
Webhookエンドポイントなど、外部サービスからのリクエストを受け付ける場合に使用します。ただし、別途認証(署名検証など)を実装することを推奨します。
CSRFトークン取得用のエンドポイント#
モバイルアプリやその他のクライアントからCSRFトークンをオンデマンドで取得できるエンドポイントを提供する方法もあります。
1
2
3
4
5
6
7
8
|
@RestController
public class CsrfController {
@GetMapping("/csrf")
public CsrfToken csrf(CsrfToken csrfToken) {
return csrfToken;
}
}
|
このエンドポイントを使用する場合、セキュリティ設定でアクセスを許可します。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/csrf").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
|
クライアントは、アプリケーション起動時、認証成功後、ログアウト後にこのエンドポイントを呼び出して新しいCSRFトークンを取得します。
テストでのCSRF対応#
Spring Security Testを使用したテストでは、CSRFトークンを簡単に含めることができます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
class CsrfProtectionTest {
@Autowired
private MockMvc mockMvc;
@Test
void postWithValidCsrfToken_shouldSucceed() throws Exception {
mockMvc.perform(post("/api/data")
.with(csrf()) // CSRFトークンを含める
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\": \"test\"}"))
.andExpect(status().isOk());
}
@Test
void postWithoutCsrfToken_shouldReturn403() throws Exception {
mockMvc.perform(post("/api/data")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\": \"test\"}"))
.andExpect(status().isForbidden());
}
@Test
void postWithInvalidCsrfToken_shouldReturn403() throws Exception {
mockMvc.perform(post("/api/data")
.with(csrf().useInvalidToken())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\": \"test\"}"))
.andExpect(status().isForbidden());
}
}
|
まとめ#
Spring SecurityのCSRF対策について、以下のポイントを解説しました。
- CSRF攻撃は、ユーザーの認証状態を悪用して不正なリクエストを送信させる攻撃であり、CSRFトークンによって防御する
CsrfFilterは、状態変更を伴うHTTPメソッド(POST、PUT、DELETE、PATCH)に対してトークン検証を行う
- SPAでは
CookieCsrfTokenRepositoryを使用し、JavaScriptからトークンを読み取れるようにする
- Spring Security 6.4以降では
spa()メソッドで簡潔に設定できる
- REST APIでCSRFを無効化する場合は、認証方式とクライアントの種類を考慮して判断する
アプリケーションの特性に応じて適切なCSRF対策を選択し、セキュアなWebアプリケーションを構築してください。
参考リンク#