CSRFと SameSite Cookie: Spring Security におけるディフェンス機構

1. 概要

CSRF(Cross-Site Request Forgery)は古典的な Web の脆弱性ですが、今でも多くの人が誤解しています。多くの人は、ただ SameSite=Strict を有効にすれば CSRF トークンは不要だと考えていますが、これは間違いです。この記事では、CSRF 攻撃の仕組み、SameSite cookie の実際の役割、Spring Security における正しい CSRF 保護の設定方法を説明します。読み終わったら、いつ CSRF トークンが必要か、いつ SameSite が十分か、そして検証テストの書き方が分かるようになります。

2. CSRF はどのように機能するか

PaymentService という送金エンドポイントを持つコントローラーがあり、セッション cookie で認証されているとします。

@RestController
@RequestMapping("/api/payments")
public class PaymentController {

    private final PaymentService paymentService;

    public PaymentController(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    @PostMapping("/transfer")
    public ResponseEntity<Void> transfer(@RequestParam String toAccount,
                                          @RequestParam BigDecimal amount,
                                          HttpServletRequest request) {
        String customerId = (String) request.getSession().getAttribute("customerId");
        paymentService.transfer(customerId, toAccount, amount);
        return ResponseEntity.ok().build();
    }
}

顧客がすでにログインしている場合(browser がセッション cookie を持っている)、悪質なサイト evil.com は単にこのフォームを埋め込むだけでいいのです。

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

顧客が evil.com を開くと、browser は自動的に bank.example.com の cookie をリクエストに付与します。これは browser が送信元ドメインを気にせず、宛先ドメインだけを気にしているからです。サーバーはセッションに関する有効なリクエストを受け取り、送金を実行します。これが CSRF の本質です:browser が自動的に認証情報(cookie)をリクエストに付与することを利用した攻撃です。

注: CSRF は「環境」認証メカニズム(cookie など)の場合のみ危険です。Authorization: Bearer <token> ヘッダーを使用し、クライアントが自分で設定する必要がある場合(JavaScript は CORS のため cross-site でこれを実行できない)、古典的な CSRF は適用されません。

SameSite は cookie 上のフラグ(flag)で、browser に以下を指示します:このcookieを cross-site リクエストと共に送信するかどうか。3 つの値があります。

値 動作
Strict Cookie は同じサイト内のリクエスト(ファーストパーティのナビゲーション)のみで送信されます。別のサイトからのリンククリックの場合でも送信されません。
Lax トップレベルナビゲーション GET(ユーザーがリンククリック)のときに cookie が送信されますが、別のサイトからの POST / サブリクエストでは送信されません。
None Cookie は常に cross-site で送信され、Secure を伴う必要があります。

Spring Boot で application.yml 経由で設定:

server:
  servlet:
    session:
      cookie:
        same-site: lax
        secure: true
        http-only: true

same-site: lax の場合、上記の例の CSRF リクエスト(evil.com からのフォーム自動送信による POST)はセッション cookie を含みません。最新の browser は自動的にこれをブロックします。これが 2020 年以降 Chrome のデフォルトが Lax になった理由で、フォームベースの CSRF の大部分をブロックできるのです。

3.1. SameSite が十分でない理由

SameSite は完全なソリューションではありません。いくつかの理由があります:

  • サブドメインテイクオーバー:例えば、あなたのサイトの blog.example.com が侵害されても、app.example.com と同じ「サイト」と見なされます。cookie は送信されたままです。
  • 古い browser:一部の browser / バージョンでは SameSite を完全にサポートしていないため、このフラグを無視して、従来通り cookie を送信します。
  • 副作用を持つ GET リクエスト:もしあなたのエンドポイントが誤って GET でデータを書き込む操作を許可している場合(アンチパターンですが存在します)、Lax でもトップレベルナビゲーションの GET は cookie を付与できます。
// アンチパターン:GET が副作用を持つ場合、Lax でもブロックできない
@GetMapping("/orders/{id}/cancel")
public ResponseEntity<Void> cancelOrder(@PathVariable Long id) {
    orderService.cancel(id); // <img src="...">を経由して露出された場合、危険
    return ResponseEntity.ok().build();
}

したがって、SameSite は**追加的な防御層(多層防御)**と見なされるべきであり、CSRF トークンの代替ではありません。

4. CSRF トークン:メインの保護メカニズム

CSRF トークンはダブルサブミット / シンクロナイザートークンの原則に基づいて機能します。サーバーがランダムなトークンを生成し、セッションまたは別の cookie に保存し、データを変更するすべてのリクエスト(POST/PUT/DELETE)が本体またはヘッダー内でこのトークンを送信する必要があります。evil.com の攻撃者は同一オリジンポリシーのため、トークンを読むことができないため、有効なリクエストを偽造することができません。

4.1. Spring Security を追加

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

デフォルトでは、Spring Security 6.x は GET/HEAD/OPTIONS/TRACE 以外のすべてのリクエストに対してCSRF 保護を有効にします。

SPA(React、Vue)が AJAX 経由で API を呼び出す場合、CookieCsrfTokenRepository を使用して、JS が cookie からトークンを読み取り、ヘッダーに付与します。

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
            )
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/payments/**").authenticated()
                .anyRequest().permitAll()
            );
        return http.build();
    }
}

説明:

  • withHttpOnlyFalse():XSRF-TOKEN cookie は JS で読み取ることができる必要があります(document.cookie)。そのため、HttpOnly を設定することはできません。これは意図的なトレードオフです。このトークンはセッション cookie ほど機密ではなく、その喪失だけでは、攻撃者が cross-site で読むことができない場合、攻撃に十分ではありません。
  • CsrfTokenRequestAttributeHandler:トークンの遅延ロードを処理し、最初のリクエストからすぐにトークンをレンダリングする必要を回避します(SPA 向けに最適化)。

クライアント側では、各リクエストで XSRF-TOKEN cookie を読み取り、X-XSRF-TOKEN ヘッダーを設定する必要があります。

function getCsrfToken() {
  return document.cookie
    .split('; ')
    .find(row => row.startsWith('XSRF-TOKEN='))
    ?.split('=')[1];
}

fetch('/api/payments/transfer', {
  method: 'POST',
  headers: { 'X-XSRF-TOKEN': getCsrfToken(), 'Content-Type': 'application/json' },
  body: JSON.stringify({ toAccount: 'ACC123', amount: 100 })
});

evil.com の攻撃者は bank.example.com の XSRF-TOKEN cookie を読むことができません(同一オリジンポリシー)。そのため、正しいヘッダーを設定することができません。フォームの自動送信ではカスタムヘッダーを設定できないため、SameSite=None の場合でも、リクエストは即座にブロックされます。

4.3. 従来的なフォーム(サーバーサイドレンダリング)での設定

Thymeleaf を使用する場合、トークンはフォームに直接レンダリングされます。

<form th:action="@{/api/orders}" method="post">
  <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
  <input type="text" name="productName"/>
  <button type="submit">注文を作成</button>
</form>

Spring MVC は CsrfRequestDataValueProcessor のおかげで、自動的に _csrf をモデルに挿入します。追加のコーディングは不要です。

5. SameSite と CSRF トークンの組み合わせ:多層防御

この 2 つのレイヤーは同じ問題の異なる側面を解決するため、同時に使用する必要があります。

server:
  servlet:
    session:
      cookie:
        same-site: lax
        secure: true
        http-only: true
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.csrf(csrf -> csrf
        .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
    );
    return http.build();
}
  • セッション cookie 上の SameSite=Lax は、cross-site リクエストの大部分を browser 段階で自動的にチェックし、サーバーに到達する前にブロックします。
  • CSRF トークンは、SameSite がカバーできないケースをブロックします(古い browser、サブドメインの侵害、またはクロスサイトの埋め込みをサポートする必要があるため SameSite=None を意図的に使用している場合)。

6. CSRF 保護を検証するテストの記述

spring-security-test を使用して MockMvc でテストし、トークンが不足しているときにエンドポイントがブロックされることを確認します。

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>
@WebMvcTest(OrderController.class)
@Import(SecurityConfig.class)
class OrderControllerCsrfTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void shouldRejectPostWithoutCsrfToken() throws Exception {
        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"productName\":\"Laptop\"}"))
            .andExpect(status().isForbidden());
    }

    @Test
    void shouldAcceptPostWithValidCsrfToken() throws Exception {
        mockMvc.perform(post("/api/orders")
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"productName\":\"Laptop\"}"))
            .andExpect(status().isOk());
    }
}

HTTP レイヤーと関係のないビジネスロジックについては AssertJ を使用してください。例えば、各セッションごとにトークンが一意に生成されることを確認します。

@Test
void csrfTokenShouldBeUniquePerSession() {
    CsrfTokenRepository repository = new HttpSessionCsrfTokenRepository();
    CsrfToken token1 = repository.generateToken(new MockHttpServletRequest());
    CsrfToken token2 = repository.generateToken(new MockHttpServletRequest());

    assertThat(token1.getToken()).isNotEqualTo(token2.getToken());
}
  • 最初のテストは、トークンが不足している POST リクエストが 403 Forbidden を返すことを確認します。これは Spring Security のデフォルト動作です。
  • with(csrf()) は spring-security-test のヘルパーで、自動的に有効なトークンを模擬リクエストに付与します。
  • 3 番目のテストは、トークンがセッション間で再利用/推測されないことを保証します。

7. よくあるエラーと、CSRF トークンが不要な場合

エラー 1 — API 全体の CSRF を「テストを簡単にするため」に無効化する:

// アンチパターン:本番環境ではこれを行わないでください
http.csrf(csrf -> csrf.disable());

API が完全にステートレスの場合のみ合理的です。つまり、Authorization ヘッダーの JWT で認証し、認証に cookie を使用しないため、「環境」認証情報がありません。

エラー 2 — 結果を理解せずに SameSite=None を使用する:

server:
  servlet:
    session:
      cookie:
        same-site: none  # クロスサイトの iframe に必要ですが、CSRF リスクが再度開かれます
        secure: true

もしあなたが None を強制される必要がある場合(例:第三者の iframe にアプリを埋め込む)、必須で CSRF トークンを有していることを確認します。スキップしてはいけません。

エラー 3 — 副作用を持つ GET リクエストに CSRF を適用する:

GET でデータを削除/更新するように設計しないでください。Spring Security の CSRF フィルタはデフォルトで GET を保護しません(RFC 7231 に従うと GET は safe メソッドと見なされるため)。REST 原則に違反するとき、自分で脆弱性を開きます。

注: もしあなたのバックエンドが同じドメイン上で web アプリ(cookie ベース)とモバイル / サードパーティ API(トークンベース)の両方に提供する場合、RequestMatcher に基づいて CSRF をセグメント化してください。cookie を使用するパスに対してのみ CSRF を有効にし、Bearer トークンベースのパスに対しては無効にします。

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.csrf(csrf -> csrf
        .ignoringRequestMatchers("/api/mobile/**") // JWT 認証、cookie なし
    );
    return http.build();
}

8. まとめ

CSRF トークンと SameSite cookie は、同じ問題の 2 つの異なる側面を解決します:browser が別のサイトからの有害なリクエストと一緒に認証情報を自動的に送信するのを防ぎます。SameSite は browser 層での防御層で、簡単に有効にできますが、包括的ではありません。CSRF トークンはアプリケーション層での防御層で、より信頼できますが、サーバーとクライアントの両方で追加のコーディングが必要です。セッション cookie を使用するシステムの場合は、両方を同時に使用してください。Bearer トークンで認証されたステートレス API の場合、CSRF フィルターをスキップできますが、cookie に関連するその他のリスクを防ぐために SameSite を保持します。

web-securitycsrfspring-securitycookiespring-boot