この記事の現象を確認した Laravel のバージョンは 8.83.4 です。laravel/laravel と laravel/framework のソースコードを見る感じ他の Laravel のバージョンでも恐らく同じ現象が起き、同様の方法で解決できます。
Laravel と CSRF の説明
Laravel は PHP のフレームワークであり、webサイトの構築によく使われます。webサイトはよく攻撃され、攻撃方法の一つに CSRF(クロスサイトリクエストフォージェリ)というものがあります。これはおおまかに次の手順から成る方法です。
- CSRFは攻撃対象とは別のwebページを用意
- 攻撃対象サイトにログイン済みユーザーをそのwebページにアクセスさせる
- webページ上からログイン済みユーザーとして攻撃対象のサイトに悪意のあるリクエストを飛ばす
詳しくは次リンクがたよりになります。
情報処理推進機構:情報セキュリティ:脆弱性関連情報の取扱い:知っていますか?脆弱性 (ぜいじゃくせい)/3. CSRF (クロスサイト・リクエスト・フォージェリ)
これで完璧!今さら振り返る CSRF 対策と同一オリジンポリシーの基礎 – Qiita
Laravel にはこの攻撃への対策が組み込んであります。
これはサーバー側でトークンを生成しセッションに保存、生成されたトークンをリクエストのボディなりヘッダーなりに入れて送信、送られてきたリクエストのトークンとサーバー側で保存されているトークンが一致するならばOK、といった手順で成り立っています。デフォルトでは次の様にフォームを POST するときには @csrf を入れないと 419 エラー、CSRFエラーが返ってくるのがこれです。
<form method="POST" action="/profile"> @csrf </form>
この CSRF 保護機能は axios の様な非同期でリクエストを送信できるライブラリで POST する場合も働いています。この時プログラマ側で特別 CSRF についての処理を書く必要がないのはライブラリとLaravelがCSRF対策のトークンのデファクトスタンダードに従っていい感じに処理をしてくれているからです。今回の取り上げる問題は大体この axios で GET 以外のメソッドでリクエストを送っている時に起きます。
Axios
【Laravel】【JavaScript】axios と Laravel の通信は CSRF 関連の対策を自動でしてくれる – 株式会社シーポイントラボ | 浜松のシステム・RTK-GNSS開発
問題と解決方法
この様なありがたいCSRFトークン発行機能ですが時折、暴発してプログラマーを悩ませます。特に一見、正しくトークンを送っているのにも関わらずCSRFエラーが返ってきた場合は混乱します。この状況に陥る原因と対処法の一つを紹介します。
この記事で紹介する問題が起きる場合は異なるミドルウェアグループが適用されるURLを使いつつリクエストを送る場合です。具体的には次の様なコードの場合です。
// /app/Http/Kernel.php /** * The application's route middleware groups. * * @var array */ protected $middlewareGroups = [ // デフォルトのミドルウェアグループの web をコピペして // 複数のミドルウェアグループを作成 'web-common' => [ \App\Http\Middleware\EncryptCookies::class, \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, \Illuminate\Session\Middleware\StartSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class, \App\Http\Middleware\VerifyCsrfToken::class, \Illuminate\Routing\Middleware\SubstituteBindings::class, ], 'web-member' => [ \App\Http\Middleware\EncryptCookies::class, \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, \Illuminate\Session\Middleware\StartSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class, \App\Http\Middleware\VerifyCsrfToken::class, \Illuminate\Routing\Middleware\SubstituteBindings::class, // member専用ミドルウェア色々 ], ];
// /app/Providers/RouteServiceProvider.php public function boot() { $this->routes(function () { // それぞれのミドルウェアグループを適用したルーティング Route::middleware('web-common') ->group(base_path('routes/web_common.php')); Route::middleware('web-member') ->group(base_path('routes/web_member.php')); }); }
この様な別々のミドルウェアグループでCSRF対策ミドルウェアである\App\Http\Middleware\VerifyCsrfToken::class
を適用したルーティングを作成し、片方のルーティング上のページからもう片方のルーティング上のURLに向けてPOSTをした場合、一見正しくCSRF トークンを送っているにもかかわらず、CSRFエラーが発生する場合があります。
対処方法としては次の二つが挙げられ、どちらか片方でも適用すれば直ります。
一つ目はミドルウェアグループの共通部分を括りだし、それぞれに適用する方法です。具体的には次のコードになります。
// /app/Http/Kernel.php /** * The application's route middleware groups. * * @var array */ protected $middlewareGroups = [ // デフォルトのミドルウェアグループの web をそのままにする 'web' => [ \App\Http\Middleware\EncryptCookies::class, \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, \Illuminate\Session\Middleware\StartSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class, \App\Http\Middleware\VerifyCsrfToken::class, \Illuminate\Routing\Middleware\SubstituteBindings::class, ], 'web-common' => [ ], 'web-member' => [ // member専用ミドルウェア色々 ], ];
// /app/Providers/RouteServiceProvider.php public function boot() { $this->routes(function () { // それぞれのミドルウェアグループを適用したルーティングに // 共通部分である web も適用する Route::middleware('web') ->middleware('web-common') ->group(base_path('routes/web_common.php')); Route::middleware('web-common') ->middleware('web-member') ->group(base_path('routes/web_member.php')); }); }
もう一つの方法はミドルウェアの適用順をアプリケーションのコード上で明示することです。これは次の様にできます。
// /app/Http/Kernel.php /** * The application's route middleware groups. * * @var array */ protected $middlewareGroups = [ // デフォルトのミドルウェアグループの web をコピペして // 複数のミドルウェアグループを作成 'web-common' => [ \App\Http\Middleware\EncryptCookies::class, \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, \Illuminate\Session\Middleware\StartSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class, \App\Http\Middleware\VerifyCsrfToken::class, \Illuminate\Routing\Middleware\SubstituteBindings::class, ], 'web-member' => [ \App\Http\Middleware\EncryptCookies::class, \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, \Illuminate\Session\Middleware\StartSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class, \App\Http\Middleware\VerifyCsrfToken::class, \Illuminate\Routing\Middleware\SubstituteBindings::class, // member専用ミドルウェア色々 ], ]; // ミドルウェアの適用の優先順位を明示 // @see https://readouble.com/laravel/8.x/ja/middleware.html?header=%E3%83%9F%E3%83%89%E3%83%AB%E3%82%A6%E3%82%A7%E3%82%A2%E3%81%AE%E9%A0%86%E5%BA%8F protected $middlewarePriority = [ // laravel/laravel でプロジェクト作成時にあるミドルウェアである // app 以下の EncryptCookies を最優先と明示 \App\Http\Middleware\EncryptCookies::class, // ここから下は vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php にある // $middlewarePriority のコピペ \Illuminate\Cookie\Middleware\EncryptCookies::class, \Illuminate\Session\Middleware\StartSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class, \Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class, \Illuminate\Routing\Middleware\ThrottleRequests::class, \Illuminate\Session\Middleware\AuthenticateSession::class, \Illuminate\Routing\Middleware\SubstituteBindings::class, \Illuminate\Auth\Middleware\Authorize::class, ];
原因
なぜこれで直るのかというと元々のエラーの原因が期待と異なる順番で適用されたミドルウェアにより、リクエストで送られたトークンとサーバー側で保存されていたトークンが不一致を起こす点にあるからです。
CSRF対策処理で重要なミドルウェアは\App\Http\Middleware\EncryptCookies::class
, \Illuminate\Session\Middleware\StartSession::class
, \App\Http\Middleware\VerifyCsrfToken::class
の三つです。この三つはそれぞれリクエストを受け取った際にクッキーの中身の復号、セッションの読み取りor開始、CSRF攻撃の防止、を行います。そしてこれら三つは並べた順の逆順で依存しています。VerifyCsrfToken はセッションからサーバ側トークンを読み出し、StartSessionはクッキーからセッションを読み出します。つまり、EncryptCookies、StartSession、VerifyCsrfTokenの順に実行されない場合、正常なサーバ側トークンが読み出せず、CSRF対策の処理が壊れます。
ですのでこの問題が起こった時の最小の修正コードは次になります。
// /app/Http/Kernel.php // このミドルウェア適用優先順位を追加 protected $middlewarePriority = [ \App\Http\Middleware\EncryptCookies::class, \Illuminate\Session\Middleware\StartSession::class, \App\Http\Middleware\VerifyCsrfToken::class, ];
ちなみに、そもそもなぜこの問題を踏みやすいのかというと laravel/laravel のソースコードに原因の一端があります。
laravel/laravel は Laravel 本家が用意してくれている Laravel を動かす PHP ソースコードのセットです。composer create-project laravel/laravel
のコマンドで Laravel を用意するのは Laravel のチュートリアルであり、世にある多くの Laravel のボイラープレートもこれを利用しています。実際便利です。
このソースコード内においては app/Http/Middleware/EncryptCookies.php という Laravel 既存クラスの \Illuminate\Cookie\Middleware\EncryptCookies を継承したミドルウェアクラスがあり、それを使う様にミドルウェアの設定がセットされています。しかしながら app/Http/Kernel.php のミドルウェア適用優先度は明示されていません。
これにより、CSRF対策処理の最初に実行されるべき app/Http/Middleware/EncryptCookies.php の実行優先度が未定義になってしまっています。この場合EncryptCookies の実行順は Laravel 以下の低レイヤーの実装依存であり不定です。たまたま laravel/laravel の用意するミドルウェアグループ web のままならば正常に動作します(1つ目の対処方法で解決するのはこの状態を再現するためです)が、web とは別にミドルウェアグループを増やし、そのうちで\App\Http\Middleware\EncryptCookies::class
, \Illuminate\Session\Middleware\StartSession::class
, \App\Http\Middleware\VerifyCsrfToken::class
を使う、と定義すると実行順が壊れてCSRFエラーが発生し続ける自体が起きる時があります。