CSRF対策を「SameSite=Laxが既定になったから、もう要らない」で済ませると、送金や設定変更の事故が残ります。SameSite=Laxが止めるのは、他サイトから送られるPOSTだけです。副作用のあるGET、同じドメインの兄弟サブドメインからのPOST、属性なしCookieの作成から2分間は、当サイトの実測(Chromium 141)でも送金が通りました。

連載「事故から逆算するWebセキュリティ」の第21回は、CSRFの防御をどう重ねるかを、検証用のアプリで確かめながら整理します。

WHAT LAX MISSESLaxの守備範囲

Laxは他サイトのPOSTを止める。それ以外は通った

止まった(Cookieが付かない)

SameSite=Lax を明示したCookie

  • 他サイトからのフォームPOST
  • 他サイトの img で送る副作用GET
  • 未指定Cookieの作成から2分後のPOST

通った(送金が成立)

同じ実験で攻撃が成功したもの

  • 他サイトからのリンク遷移による副作用GET
  • 兄弟サブドメインからのPOST(Strictでも)
  • 未指定Cookieの作成直後(2分以内)のPOST
実測: Chromium 141.0.7390.37(Playwright 1.56.1、headless)、2026-10-17、当サイトの検証用アプリで2回とも同じ結果

30秒でわかる(2026年10月17日時点)

  • ・Laxの範囲:止めるのは「他サイトからのPOST」。GETのリンク遷移と同じサイト内のサブドメインは通す
  • ・既定のLaxはChromium系だけ:MDNの互換性データでは、Firefoxは設定変更時のみ、Safariは未対応
  • ・実測:Chromium 141で13通り試し、Laxを明示しても3つの経路で送金が成立した
  • ・CSRF対策:GETで状態を変えない → Sec-Fetch-SiteとOriginで送り元を判定 → トークン → 重要な操作は再認証

この記事の土台にある「攻撃の入口を層で塞ぐ」考え方は、サプライチェーン攻撃とは|手口・防御ツール比較・npm 12の新機構まで実践解説 でも扱っています。

CSRFの仕組みとSameSiteが既定になるまで

CSRF(クロスサイトリクエストフォージェリ)は、ログイン中の利用者のブラウザに、別のサイトから意図しない操作を送らせる攻撃です。ブラウザはリクエストに宛先のCookieを自動で付けるので、サーバーからは本人の操作と区別できません。攻撃者が読めるのは結果ではなく「送らせること」だけですが、送金・パスワード変更・権限の付与のように一度送れば済む操作では、それで足ります(OWASP CSRF Prevention Cheat Sheet)。

攻撃のページは、見えないフォームを自動で送るだけで作れます。検証に使ったページは次のとおりです。

<!-- attacker.test に置いたページ。開いた瞬間に www.bank.test へ送金のPOSTを送る -->
<form method="post" action="https://www.bank.test:8443/transfer">
  <input name="to" value="mallory"><input name="amount" value="100000">
</form>
<script>document.forms[0].submit()</script>

ブラウザ側の防御が入った順のタイムラインです。日付ではなく、対応した版で示します(MDNのbrowser-compat-data、2026-10-17取得)。

段階 Chrome Firefox Safari
SameSite属性に対応 51 60 12
Sec-Fetch-Site を送る 76 90 16.4
属性なしCookieをLaxとして扱う 80(Edge 86) 設定項目を変えたときだけ 未対応

OWASPのチートシートには「FirefoxとEdgeも追随した」とありますが、互換性データではFirefoxの既定のLaxは設定項目(network.cookie.sameSite.laxByDefault)の扱いです。ここでは互換性データに合わせ、既定のLaxはChromium系だけとして扱います。

SameSite=Laxで止まるもの・止まらないもの(Chromium 141で実測)

検証用に、Python標準ライブラリで送金アプリ(www.bank.test)を作り、攻撃のページを別サイト(attacker.test)と兄弟サブドメイン(evil.bank.test)に置きました。3つのホスト名はChromiumの --host-resolver-rules で手元に向け、自己署名の証明書でHTTPSにしています。Sec-Fetch-Siteはhttpsのような安全とみなされるURLにしか送られないためです(Fetch Metadata仕様)。ブラウザはPlaywright 1.56.1のheadless Chromium 141.0.7390.37で、同じ手順を2回実行し、13件とも同じ結果でした。

条件 送り元 Cookie Sec-Fetch-Site 結果
属性なし・作成直後のPOST attacker.test 付いた cross-site 送金成立
属性なし・作成127秒後のPOST attacker.test 付かない cross-site 401
Lax明示・作成直後のPOST attacker.test 付かない cross-site 401
None(陽性対照)のPOST attacker.test 付いた cross-site 送金成立
Lax・リンク遷移で副作用GET attacker.test 付いた cross-site 送金成立
Lax・imgで副作用GET attacker.test 付かない cross-site 401
Lax・兄弟サブドメインからPOST evil.bank.test 付いた same-site 送金成立
Strict・兄弟サブドメインからPOST evil.bank.test 付いた same-site 送金成立

穴1:属性なしCookieの「2分の猶予」

SameSite属性を書かないCookieは、作成直後なら他サイトからのPOSTにも付きました。127秒後には付きません。これはRFC 6265bisの草案にある「Lax-allowing-unsafe」という互換モードで、属性を指定しなかったCookieだけに、作成から2分以内に限って適用してよいとされています。ログイン直後に外部の認証画面からPOSTで戻ってくる流れを壊さないための、草案自身が「一時的な移行措置」と呼ぶ扱いです。ログイン直後の2分間に攻撃のページを開かせれば通ります。SameSite=Lax を明示したCookieには、この猶予はありませんでした。

穴2:副作用のあるGET

Laxは、他サイトからのリンクをたどる遷移(トップレベルのGET)にはCookieを付けます。リンクから来た人がログインしたまま記事を読めるようにするためです。そのため GET /transfer?to=... のように状態を変えるGETは、Laxを明示しても通りました。同じGETでも、<img> で読み込ませた場合はCookieが付きません。このとき、サーバーに届いたリクエストにはOriginヘッダーがありませんでした。Originの照合だけでは、GETの送り元を判定できません。

穴3:兄弟サブドメイン

SameSiteの「サイト」は、bank.test のような登録ドメインの単位です。evil.bank.test から www.bank.test へのPOSTはsame-siteなので、LaxはもちろんStrictでもCookieが付きました。同じ親ドメインに、利用者が中身を置けるサービスや、DNSの設定が残ったまま使われていないサブドメインがあると、そこが攻撃のページになります(OWASPはサブドメインの乗っ取りで被害が広がると書いています)。

CSRF対策の部品:トークン・Fetch Metadata・Origin・独自ヘッダー

SameSiteは最後の層として残し、その手前で止めます。部品ごとの守る範囲と抜けは次のとおりです。

対策 止められるもの 抜け・前提
CSRFトークン(セッションに保存) 送り元を問わず、トークンを知らないリクエスト フォームとAJAXの全部に入れる手間。XSSがあると読まれる
署名つきダブルサブミット 状態を持たずに同上 セッションに結びつけない単純版は、兄弟サブドメインからCookieを書き込まれると破れる
Sec-Fetch-Site の判定 cross-site と same-site のPOST 古いブラウザは送らない。https 以外では送られない
Origin・Referer の照合 送り元のオリジンが違うPOST ヘッダーが無い・null のときの扱いが要る。GETでは送られないことがある
独自ヘッダー必須(JSON API) フォームからの送信。他オリジンはプリフライトで止まる CORSで許可したオリジンは送れる。text/plain を受けると抜ける
SameSite=Lax/Strict 他サイトからのPOST 副作用GET・兄弟サブドメイン・既定Laxの無いブラウザ
再認証・ワンタイムコード 上の全部が抜けたときの重要操作 毎回はかけられない

OWASPの優先順は、まずフレームワークの組み込みの対策を使い、無ければ状態を変える全リクエストにトークンを入れることです。新しいブラウザだけが相手なら、Fetch Metadataに頼ってよいとしています。ただしヘッダーを送らないブラウザに備えたOrigin・Refererの照合は、併用が必須とされています。トークンを持たない単純なダブルサブミットは、兄弟サブドメインからCookieを書き込まれると破れるため「推奨しない」扱いです。署名してセッションに結びつけた形を使い、Cookie名には __Host- を付けて、サブドメインから上書きできないようにします。CAPTCHAはCSRFの対策にならない、とも明記されています。

FETCH METADATA送り元で分ける

Sec-Fetch-Site を見て、同一オリジン以外の POST を止める

サーバーの判定

  • same-origin自分の画面から 通るwww.bank.test → www.bank.test
  • noneURL直打ち・ブックマーク 通る利用者の操作
  • same-site兄弟サブドメインから 止まるevil.bank.test → 403
  • cross-site他サイトから 止まるattacker.test → 403
ヘッダーが無い古いブラウザは Origin の照合に回す(OWASP CSRF Prevention Cheat Sheet)。403 は当サイトの実測

検証用アプリには、Fetch Metadataの判定とトークンの照合を次のように入れました(抜粋)。

SAFE = ("GET", "HEAD", "OPTIONS")
sfs, origin = headers.get("Sec-Fetch-Site"), headers.get("Origin")

# Fetch Metadata:同一オリジン以外からの状態変更は止める。ヘッダーが無ければ Origin を照合
if method not in SAFE:
    if sfs in ("cross-site", "same-site") or (sfs is None and origin != "https://www.bank.test:8443"):
        return 403

# トークン:セッションに保存した値と、フォームの値を一定時間で比べる
if not (session and hmac.compare_digest(form.get("csrf", ""), session["csrf"])):
    return 403

セッションCookieを SameSite=None にして「Cookieは必ず付く」状態で試すと、Fetch Metadataの判定は attacker.test(cross-site)と evil.bank.test(same-site)からのPOSTを403で止め、自分の画面からのPOST(same-origin)だけを通しました。トークンの照合も、トークンを持たない evil.bank.test からのPOSTを403で止め、正規の画面からのPOSTは通しました。

フレームワークのCSRF対策は same-site の扱いが違う

主要なフレームワークは組み込みの対策を持っていますが、兄弟サブドメインをどう扱うかが分かれます。各ソースは2026-10-17に公式リポジトリの原稿で確認しました。

フレームワーク 既定の判定 same-site のPOST
Django(CsrfViewMiddleware) トークン+Originをホストと CSRF_TRUSTED_ORIGINS で照合。HTTPSでOriginが無ければRefererを厳密に照合 Originがホストと違えば止まる
Go 1.25 http.CrossOriginProtection Sec-Fetch-Site が same-origin か none 以外なら拒否。無ければOriginとHostを照合 止まる
Rails(main ブランチ・未リリースの8.2) 新規アプリの既定が :header_only(Sec-Fetch-Site だけで判定) 通る
Next.js Server Actions POSTだけを受け、OriginをHost(または X-Forwarded-Host)と照合 Originが違えば止まる

Railsのmainブランチのコードは same-origin と same-site を同じく通し、cross-site は trusted_origins に入っていれば通します。最新の公開版v8.1.4にはこの方式は入っていません。Rails 8.2で :header_only を使い、同じ親ドメインに信頼できないサブドメインがあるなら、same-site を止める判定を自分で足す必要があります。Goはヘッダーが両方とも無いリクエストを「同一オリジンかブラウザ以外」とみなして通す点にも注意します。

Next.jsの照合はServer Actionsの説明に書かれたもので、自分で書いたRoute Handlersについては同じ照合の記載がありません。Server Actions周辺の最近の脆弱性は Next.js CVE-2026-96889の記事 にまとめています。

CSRF対策の重ね方と、ログインCSRF・再認証

LAYER THE DEFENSES重ねる順番

GETで状態を変えないうえで、3つの層で止める

  1. 1.Fetch Metadata+Origin

    same-origin以外のPOSTを403に(兄弟サブドメインも止める)

  2. 2.フレームワークのCSRFトークン

    セッションに結びつけて照合(古いブラウザも止める)

  3. 3.重要な操作は再認証

    送金・メール変更・パスワード変更(XSSが通った後も残る)

組み合わせの考え方は OWASP CSRF Prevention Cheat Sheet(2026-10-17確認)に基づく当サイトの整理

重ねる順番は次のとおりです(当サイトの整理)。

・状態を変える処理をGETで受けない:Laxの穴2を根本から消す。OWASPも原則の1つに挙げている
・Sec-Fetch-Site と Origin で送り元を判定する:兄弟サブドメインを信頼しないなら same-site も止める。まずはログだけ取り、正規の流れが止まらないか確かめてから403に切り替える
・フレームワークのトークンを有効にしたままにする:Fetch Metadataを送らないブラウザと、ヘッダーを書き換えるプロキシへの備え
・セッションCookieは __Host- と SameSite=Lax を明示する:2分の猶予を消し、サブドメインからの上書きを防ぐ
・送金・メールアドレス変更・パスワード変更は再認証:XSSが1つあれば、どのCSRF対策も破られる(OWASP)

Cookieの属性そのものの選び方は トークン窃取を多層で止める——セッション・AI推論・課金まで含めた防御パターン でも触れています。

ログインCSRFも塞ぐ

ログイン前は奪うセッションが無いので対策を省きがちですが、ログインCSRFは「攻撃者のアカウントで被害者をログインさせる」攻撃です。被害者が気づかずにカード情報や検索履歴を入れると、攻撃者のアカウントに残ります。OWASPは、ログイン前のセッションを作ってログインフォームにトークンを入れ、ログイン後はセッションを作り直す方法を挙げています。今回の検証ではログインCSRFは試していません。

自分のアプリを確かめる

影響を受けるのは、Cookieでログイン状態を持ち、状態を変える入口にトークンも送り元の判定も無いアプリです。検証環境で、自分のセッションCookieを付けたまま「他サイトから来た」POSTを再現します。期待する応答は、cross-site と same-site のどちらも403です。

SID='<検証環境で自分がログインしたときの sid の値>'
for site in cross-site same-site; do
  curl -s -o /dev/null -w "$site %{http_code}\n" -X POST https://staging.example.com/transfer \
    -b "sid=$SID" -H 'Origin: https://attacker.example' -H "Sec-Fetch-Site: $site" -d 'to=test&amount=1'
done

検証用アプリに同じ形で送ると、無防備な /transfer は2つとも200、Fetch Metadataの判定とトークンの照合を入れた入口は2つとも403でした。Sec-Fetch-Site を外してOriginだけを付けた場合も、判定を入れた入口は403です。これは1つの入口の応答を見る構成の確認で、この結果だけでアプリ全体が安全とは言えません。403が返っても、別の入口や副作用のあるGETが無防備なことはあり、200が返っても正規の画面では別の経路で守られていることがあります。200が返った入口は、状態を変える処理の一覧に入れて、上の順で塞ぎます。GETの入口も同じく、ルーティングの定義から送金・削除・設定変更をしているものを探します。

参照ソース