よくあるCSRF対策
まず、徳丸本をベースにした基本的なCSRFの対策を見てみます。
<?php
session_start();
if (empty($_SESSION['token'])) { // トークンが空なら生成
$token = bin2hex(openssl_random_pseudo_bytes(24));
$_SESSION['token'] = $token;
} else { // トークンがもともとあればそれを使う
$token = $_SESSION['token'];
}
?>
<form action="update-password.php">
新パスワード:<input name="pwd" type="password"><br>
<input type="hidden" name="token" value="<?php echo htmlspecialchars($token, ENT_COMPAT, 'UTF-8'); ?>>
<input type="submit" value="パスワード変更">
</form>
上記のように、CSRF対策の王道は、GET Requestに応じてサーバ側でCSRFトークンを生成し、セッション変数と<input type=”hidden”>のvalueにトークンをセットしたうえでResponseを返す方法です。
POST等の処理を捌く際は、リクエストボディのCSRFトークンをサーバで検証します。トークンが存在しない場合、もしくはトークンが一致しない場合は正規のリクエストではないと判断できるため、処理を実行しません。
フレームワークでのCSRF対策(Laravel)
PHPフレームワークであるLaravelがどのようなCSRF対策を行っているかを見てみます。
Laravelは、アプリケーションによって管理されているアクティブなユーザーセッションごとにCSRF「トークン」を自動的に生成します。 アプリケーションで”POST”、”PUT”、”PATCH”、”DELETE” HTMLフォームを定義するときはいつでも、CSRF保護ミドルウェアがリクエストを検証できるように、フォームに非表示のCSRF_tokenフィールドを含める必要があります。便利なように、@csrf Bladeディレクティブを使用して、非表示のトークン入力フィールドを生成できます。
Laravel 8.x CSRF保護 | readouble.com
Laravelの場合、上記のようにformタグには@csrfの記述が必須で、@csrfがないformをsubmitした場合は419エラーになります。
実際にソースを見てみると、@csrfが_tokenというhiddenのinputに自動的に変換されていることが分かります。
<input type="hidden" name="_token" value="5HZIGWmejlQWp4CWOrRTbza3P2xj5oei0VNHxSPA">
Laravelもhiddenに格納したトークンを使ってCSRF対策を施しているということです。
CSRFトークンの寿命
Laravelのドキュメントに以下の記載があります。
トークンはユーザーのセッションに保存され、セッションが再生成されるたびに変更される。
Laravel 8.x CSRF保護 | readouble.com
具体的には、Laravelはログイン中のほとんどのリクエストに対してセッションIDとCSRFトークンを再生成しています。
LaravelアプリケーションスターターキットまたはLaravel Fortifyのどちらかを使用している場合、Laravelは認証中にセッションIDを自動的に再生成します。
Laravel 8.x HTTPセッション | readouble.com
CSRFトークンの再生成は、フォームの存在しないページのGETに対しても行われます。ただし、例えば404の時などには再生成は行われません。
CookieのXSRF-TOKEN
LaravelのResponseを見てみると、セッションID以外にもXSRF-TOKENというCookieをリクエスト毎に払い出していることが分かります。
Set-Cookie: XSRF-TOKEN=eyJpdiI6I.....In0%3D; expires=Wed, 25-Aug-2021 11:34:43 GMT; Max-Age=7200; path=/; samesite=lax
Set-Cookie: laravel_session=eyJpdiI6I.....In0%3D; expires=Wed, 25-Aug-2021 11:34:43 GMT; Max-Age=7200; path=/; httponly; samesite=lax
Content-Length: 350
hiddenでCSRFトークンを送信しているのであれば、このCookieにあるXSRF-TOKENは何をしているのかという話になると思いますが、これは主にAngularやAxiosなどのajaxライブラリにおけるCSRF対策に使用されます。
Laravelはフレームワークが生成する各レスポンスに含めるXSRF-TOKEN暗号化クッキーへ、現在のCSRFトークンを保存します。クッキー値を使用して、X-XSRF-TOKENリクエストヘッダを設定できます。
AngularやAxiosなどの一部のJavaScriptフレームワークとライブラリは、同じオリジンのリクエストでその値を自動的にX-XSRF-TOKENヘッダへ配置するため、このクッキーは主に開発者の利便性のために送信されます。
Laravel 8.x CSRF保護 | readouble.com
実際にAxiosでPOSTを実行した際のRequestが以下です。
X-XSRF-TOKEN: eyJpdiI6IkRvZ016NjZsNW0ySlhRZWNXeDlQanc9PSIsInZhbHVlIjoic1NSdXlLUDJndUVJQi9MbEhJZitrVlg2aWJQV2VCYkJVaHAycHN4TWdKVkcvT1NiY0ZNMHM1YXd2NDNHUURHbGNBYjR5UG43cVBGNUNFT2ZKTDZ2YUxnVWlZK3pTcURvM0orWmZ3RnV3NGUwalFDSUZaTS9vYnBYckk4REdBTzMiLCJtYWMiOiJhYTMzYjk5MWZhYjhmMjdkOWZjMzk0OTVhZWE4ZTkyZDRjYmM4Y2M2MmJjMjI4ODgzOWZkM2IwMWY5ODg3NWQ1In0=
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36
Content-Type: application/json;charset=UTF-8
Origin: http://localhost
Referer: http://localhost/register
Accept-Encoding: gzip, deflate
Accept-Language: ja,en-US;q=0.9,en;q=0.8
Cookie: XSRF-TOKEN=eyJpdiI6IkRvZ016NjZsNW0ySlhRZWNXeDlQanc9PSIsInZhbHVlIjoic1NSdXlLUDJndUVJQi9MbEhJZitrVlg2aWJQV2VCYkJVaHAycHN4TWdKVkcvT1NiY0ZNMHM1YXd2NDNHUURHbGNBYjR5UG43cVBGNUNFT2ZKTDZ2YUxnVWlZK3pTcURvM0orWmZ3RnV3NGUwalFDSUZaTS9vYnBYckk4REdBTzMiLCJtYWMiOiJhYTMzYjk5MWZhYjhmMjdkOWZjMzk0OTVhZWE4ZTkyZDRjYmM4Y2M2MmJjMjI4ODgzOWZkM2IwMWY5ODg3NWQ1In0%3D; laravel_session=eyJpdiI6ImdXalJtc1YxVkFTU3M3czNyUUdQSGc9PSIsInZhbHVlIjoiMWx2bnhnM0VpZ1RobmtYVHpXZEFWN2M1azBxazFCWVRJWVM5VURiNmRiZ2RUWlA0UnVPdmVhN3R4MkE4SDRjcmxjUU1pNXNJQnB3VEJnYko5K2tzKytuUkNlbmk0QTV6ZnpYaWErZWlUMDF6dkJxVXpiZFRINDlGQzJDMkxpeVkiLCJtYWMiOiIwYmZkZWM4MGY3MDA5Mzg5NzY0ODQxMDk3MTQ1NGNmOTE5MzViOTg0NjI4ZWZhNWE3Y2I0YmU5MjI4YTUxM2Y5In0%3D
Connection: close
{"name":"user","password":"qwerty"}
ボディにCSRFトークンは無く、X-XSRF-TOKENというヘッダが追加されています。
X-XSRF-TOKENとXSRF-TOKENの値は同じです(パーセントエンコーディングされているかいないかの違いはあります)。
LaravelがCookieの名前をXSRF-TOKENにしているのは、AngularやAxiosなどの仕様に従っているからです。
例えばAxiosのconfigには以下のような記述があります。
// `xsrfCookieName` is the name of the cookie to use as a value for xsrf token
xsrfCookieName: 'XSRF-TOKEN', // default
// `xsrfHeaderName` is the name of the http header that carries the xsrf token value
xsrfHeaderName: 'X-XSRF-TOKEN', // default
CSRFの”クロスサイト”の判定基準
このように、CSRF対策の基本はCSRFトークンをレスポンスボディに埋め込む方法です。
CSRFの「クロスサイト」とは、CSRFトークンがない = 別サイトからのリクエストという意味になります。HTTPヘッダ等を見てクロスサイトと判断しているわけではありません。
なお、CSRFトークンを使う方法はあくまでCSRF対策のうちの一つでしかないので、他に方法が無いわけではありません。ただし、CSRFトークンが使われることが圧倒的に多いので、まずはこれだけ押さえておくといいと思います。