【Laravel】Laravelが発行したCSRFトークンをヘッダーで送っているのにCSRFエラーが常に起きる場合の解決方法と原因

  • 2022年5月18日
  • 2022年5月18日
  • Laravel

 この記事の現象を確認した Laravel のバージョンは 8.83.4 です。laravel/laravel と laravel/framework のソースコードを見る感じ他の Laravel のバージョンでも恐らく同じ現象が起き、同様の方法で解決できます。

  1. Laravel と CSRF の説明
  2. 問題と解決方法
  3. 原因

Laravel と CSRF の説明

 Laravel は PHP のフレームワークであり、webサイトの構築によく使われます。webサイトはよく攻撃され、攻撃方法の一つに CSRF(クロスサイトリクエストフォージェリ)というものがあります。これはおおまかに次の手順から成る方法です。
 

  1. CSRFは攻撃対象とは別のwebページを用意
  2. 攻撃対象サイトにログイン済みユーザーをそのwebページにアクセスさせる
  3. webページ上からログイン済みユーザーとして攻撃対象のサイトに悪意のあるリクエストを飛ばす

 詳しくは次リンクがたよりになります。

情報処理推進機構:情報セキュリティ:脆弱性関連情報の取扱い:知っていますか?脆弱性 (ぜいじゃくせい)/3. CSRF (クロスサイト・リクエスト・フォージェリ)
これで完璧!今さら振り返る CSRF 対策と同一オリジンポリシーの基礎 – Qiita

 Laravel にはこの攻撃への対策が組み込んであります。

CSRF保護 8.x 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 is a web application framework with expressive, elegant syntax. We’ve already laid the foundation for your next big idea — freeing you to create without sweating the small things.

 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エラーが発生し続ける自体が起きる時があります。

>株式会社シーポイントラボ

株式会社シーポイントラボ

TEL:053-543-9889
営業時間:9:00~18:00(月〜金)
住所:〒432-8003
   静岡県浜松市中央区和地山3-1-7
   浜松イノベーションキューブ 315
※ご来社の際はインターホンで「316」をお呼びください

CTR IMG