Rails のフォームに、なぜ見えない authenticity_token が埋め込まれているのか?
Rails でフォームの HTML を初めて見たとき、送信ボタンの近くに authenticity_token という名前の隠しフィールドがあることに気づいたはずだ。セッションを切らしたときに出る InvalidAuthenticityToken エラーに悩まされた人もいるだろう。あの見えないトークンは何を防いでいて、なぜ CORS 設定だけでは代わりにならないのだろうか。
- CSRF はログイン済みブラウザが自動で送るクッキーを悪用し、別サイトから本人になりすまして状態変更リクエストを送らせる攻撃だ
- CORS はレスポンスを読み取れるかどうかを制限するだけで、単純なリクエスト自体はブラウザから送信されてしまうため CSRF は防げない
- Rails の
authenticity_tokenは synchronizer token pattern の実装で、SameSite 属性はこれを補強する多層防御であり、どちらか一方だけでは不十分だ
CSRF は何を悪用しているのか
CSRF(Cross-Site Request Forgery、クロスサイトリクエストフォージェリ)は、悪意あるサイトが被害者のブラウザを使って、被害者がログイン中の別サイトへ本人になりすましたリクエストを送らせる攻撃だ。ブラウザはリクエスト先のドメインに対応するクッキーを、どのページから発生したリクエストかに関係なく自動で添付する。攻撃者のページに置かれた自動送信フォームが銀行サイト宛に POST を送れば、そのブラウザに残っているログインセッションのクッキーがそのまま乗っていく。
攻撃者は被害者のクッキーの中身を読むことも書き換えることもできない。読めなくても、ブラウザが代わりに正しいクッキーを添付してくれるという性質だけで攻撃は成立する。ここが XSS(クッキーの中身を盗む攻撃)との決定的な違いで、CSRF は「盗む」のではなく「他人の自動送信機能を借りて送る」攻撃だ。
攻撃者サイトを開いた被害者のブラウザ
┌─────────────────────────────┐
│ <form action="bank.example │
│ /transfer" method="POST"> │
│ <script>自動submit()</script> │
└──────────────┬──────────────┘
│ POST + bank.exampleの
│ セッションCookieを自動添付
▼
┌─────────────┐
│ bank.example │ ← 本人のリクエストと
│ サーバー │ 区別できない
└─────────────┘
CORS を設定していれば防げる、と思いきや
同一オリジンポリシーと CORS を先に学んだ人ほど「うちは CORS で他ドメインからのアクセスを制限しているから CSRF も防げているはずだ」と考えがちだ。実際には CORS が制限しているのはレスポンスをブラウザ側の JavaScript が読み取れるかどうかであり、リクエスト自体がサーバーに届くかどうかとは別の話だ。
<form> タグによる POST や画像タグによる GET は、Fetch 仕様でいう「シンプルリクエスト」に分類され、プリフライトなしでそのままサーバーに届く。CORS はブラウザがレスポンスを渡す前に Access-Control-Allow-Origin ヘッダーを見て「JavaScript に見せてよいか」を判断するだけで、届いたリクエスト自体はサーバー側の処理(この例では送金処理)をすでに実行し終えている。攻撃者は結果を読めなくても構わない。送金という副作用が起きた時点で攻撃は成功しているからだ。
銀行の窓口が、持参した通帳(クッキー)さえ本物なら、申込用紙を書いたのが本人かどうかを確かめずに手続きを進めてしまうと考えるとよい。通帳は自動的に鞄の中から出てくる仕組みになっていて、持ち主はいちいち「出す」と意識しない。CSRF トークンは、その申込用紙に「今日この窓口で発行された合言葉」を書かせることで、用紙が今まさに窓口の画面を見ている本人の手から出たものかを確認する仕組みに近い。通帳の中身を盗み見なくても、合言葉さえ知らなければ用紙は偽造できない。
Synchronizer Token Pattern — Rails の実装
この合言葉の仕組みを一般化したものが synchronizer token pattern(同期トークンパターン)だ。サーバーがセッションに紐づくランダムなトークンを生成してクライアントに渡し、状態変更リクエストにはそのトークンを添えて送り返させ、サーバー側でセッションに保存された値と一致するか検証する。OWASP のチートシートも、この方式をステートフルなアプリケーションでの標準的な CSRF 対策として挙げている。
Rails では 5.2 以降 protect_from_forgery がデフォルトで有効になっており、form_for / form_with が生成するフォームには自動的に authenticity_token という隠しフィールドが埋め込まれる。この値はセッションに紐づくトークンで、フォーム送信時に params の一部としてサーバーへ返り、セッション内の値と一致しなければ InvalidAuthenticityToken 例外を投げる。副作用のない GET リクエストはこの検証の対象外だ——読み取り専用の操作は攻撃者にとって「なりすまして実行させたい操作」にはなりにくく、対象を広げても防御効果が薄いからだ。
# app/controllers/application_controller.rb class ApplicationController < ActionController::Base protect_from_forgery with: :exception end # JS/Turbo からの Ajax リクエストは # csrf_meta_tags が出力する <meta> の値を # X-CSRF-Token ヘッダーに載せて検証を通す
ステートレス API では Double Submit Cookie
synchronizer token pattern はサーバー側にセッションとトークンの対応を保持する前提で成り立つ。トークンをサーバー側で覚えておく余地がないステートレスな API では、double submit cookie(ダブルサブミットクッキー)という別の方式が使われる。ランダム値をクッキーとリクエストパラメータ(またはヘッダー)の両方に載せ、サーバーは両者が一致するかだけを見る。攻撃者はクッキーの値を読めないので、パラメータ側に同じ値を仕込めない。
ただし素朴な実装には弱点がある。攻撃者がサブドメインを乗っ取れる、あるいはネットワーク経由でクッキーを注入できる立場にいると、同じ登録可能ドメイン宛のクッキーを上書きしてパラメータと一致させられてしまう。OWASP はこれを踏まえ、トークンをセッション ID と紐づけて HMAC で署名する「signed double submit cookie」を、単純な値の一致比較だけの実装より推奨している。
SameSite 属性は補強であって代替ではない
クッキー自体に SameSite 属性を付けて、そもそも他サイトからのリクエストに自動添付されないようにする防御もある。Strict は自サイト内のナビゲーションでしかクッキーを送らず、Lax はリンクをクリックしてのトップレベル遷移(GET)では送るが埋め込みリソースや他サイトのフォームからの POST では送らない。None は無条件に送るが Secure 属性(HTTPS 限定)とセットでなければブラウザに拒否される。2020 年 2 月リリースの Chrome 80 以降、SameSite を明示しないクッキーは Lax として扱われるようになった。
SameSite=Lax を設定しても、それだけで CSRF 対策を打ち切ってよいわけではない。Lax はトップレベルの GET 遷移でのクッキー送信を許すため、GET で副作用を起こすエンドポイント(設計としては本来避けるべきだが実在する)は素通りしてしまう。さらに SameSite は「登録可能ドメイン」単位でしか区別しないため、同じサイト内の別サブドメインが攻撃元になるケースは防げない。OWASP も SameSite をトークンベースの防御を置き換えるものではなく、多層防御の一枚として位置づけている。
Rails では InvalidAuthenticityToken が本番でだけ出るとき、多くはロードバランサー配下でセッションストアの設定がずれていたり、フォームがキャッシュされて古いトークンのまま送信されていたりする。SPA + API のような構成では、CSRF トークンをどう配布するか(初回 HTML に埋め込む/専用エンドポイントで取得する)と、Cookie の SameSite をどう設定するかをセットで設計する必要がある。
実在するサイトの Set-Cookie ヘッダーを覗くと、SameSite 属性が実際にどう使われているかを確認できる。読み取り専用の GET リクエストなので安全に試せる。
curl -sI https://github.com/ | grep -i "^set-cookie" echo "---" curl -sI https://www.google.com/ | grep -i "^set-cookie"
GitHub のセッションクッキーには HttpOnly; secure; SameSite=Lax が、Google の一部のクッキーには SameSite=strict が付いているのが確認できるはずだ。同じサイトでもクッキーの用途によって Strict と Lax を使い分けていることが分かる。
- CORS を正しく設定していれば CSRF も防げる — CORS はレスポンスを JavaScript から読めるかどうかを制限するだけで、シンプルリクエストはプリフライト無しでサーバーに届き副作用を起こしてしまう。
- SameSite=Lax を設定すれば CSRF トークンはもう要らない — Lax はトップレベルの GET 遷移でのクッキー送信を許容し、同じ登録可能ドメイン内のサブドメインも区別しないため、トークンベースの防御を代替しきれない。
- CSRF トークンをクッキーではなく localStorage に保存すれば、それだけで安全になる — localStorage は JavaScript から自由に読み書きできる領域であり、XSS 脆弱性があればそこに保存したトークンごと盗まれる。CSRF 対策と XSS 対策は別の脅威に対する別の防御であり、片方が他方の代わりにはならない。
- CSRF(クロスサイトリクエストフォージェリ)
- ログイン済みブラウザに自動送信されるクッキーを利用し、別サイトから本人になりすましたリクエストを送らせる攻撃。
- synchronizer token pattern
- サーバーがセッションに紐づくランダムなトークンを発行し、リクエストに添えて送り返させて検証する CSRF 対策。
- authenticity_token
- Rails が synchronizer token pattern を実装した際にフォームへ自動挿入する隠しフィールドの名前。
- double submit cookie
- ランダム値をクッキーとリクエストパラメータの両方に載せ、一致を検証するステートレスな CSRF 対策。
- SameSite 属性
- クッキーが他サイトからのリクエストにも自動添付されるかどうかを Strict / Lax / None で制御するクッキー属性。
- Cross-Site Request Forgery Prevention - OWASP Cheat Sheet Series — synchronizer token pattern と double submit cookie の使い分け、signed variant の必要性をまとめた一次情報。
- SameSite cookies explained - web.dev — Strict / Lax / None の挙動差と Chrome 80 でのデフォルト変更を解説した Google の公式記事。
- Cross-Site Request Forgery (CSRF) - Ruby on Rails Security Guide — authenticity_token と protect_from_forgery の具体的な実装を説明する公式ガイド。