【Laravel】CSRF対策にOriginを使う

 CSRF(Cross-Site Request Forgery、サイトをまたいだリクエストの偽造)はざっくばらんに言うと、攻撃者が用意したwebサイト等を介して被害者が認証済みのwebサイトで意図しない操作を実行させる攻撃です。これは被害者が罠サイトを開いた時、攻撃対象のサイトに被害者の認証情報でリクエストを送り、被害者のアカウントで不正な操作を行うという構造がよく例に挙げられます。この攻撃は攻撃されるサイト側で防御でき、webサイトを作るフレームワークなどにはCSRF対策機能がよく含まれています。

 LaravelはPHPのフレームワークでありwebサイトの構築を手助けしてくれます。LaravelにはCSRF対策の機能も備わっており、それはトークンによって実装されています。これはCSRFトークンという本来のサイトでのみ参照できるトークンを使い、データを変更する操作の際には正しいCSRFトークンがサーバーに送られているかをチェックするという形式です。これはCSRF対策として適切ですが、いささか不便な点もあります。もっとも問題なのは正常なリクエストであるのにユーザーに「CSRF token mismatch.」や「419|PAGE EXPIRED」といった419エラーを返してしまう場合があるという点です。これは複数のタブで同一のサイトを開き、それぞれでフォームを送信するなどといった時に起きやすいです。

 CSRF対策はトークンを用いる方法以外でも可能です。この対策方法は多くの場所で紹介されています。例えば最近あったPHPerKaigi2024で発表された次の資料が詳しいです。

CSRF対策のやり方、そろそろアップデートしませんか / Update your knowledge of CSRF protection – Speaker Deck

 このブログ記事ではLaravelのCSRF対策をトークンからOriginに差し替える方法を紹介します。

 実装はミドルウェアで行います。ミドルウェアのコードは次です。

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

/**
 * HTTPリクエストのオリジンを検証するミドルウェア。
 *
 * このミドルウェアは、入力されたHTTPリクエストのオリジンが許可されたオリジンリストに含まれているかどうかを検証します。
 * 許可されたオリジンからのリクエストのみを処理し、それ以外のオリジンからのリクエストはエラーとして拒否します。
 */
class VerifyOrigin
{
    /**
     * リクエストを処理し、オリジンが許可されているかどうかを検証します。
     * @param  Request  $request  現在のHTTPリクエスト
     * @param  Closure  $next     次に実行すべきミドルウェア
     * @return mixed 許可されたオリジンからのリクエストの場合は次のミドルウェアに進む。それ以外の場合は403エラーを返す。
     */
    public function handle(Request $request, Closure $next): mixed
    {
        if($this->isReading($request) || $this->runningTests() || $this->isOriginAllowed($request)) {
            return $next($request);
        }

        // Originが許可リストにない場合はエラーレスポンスを返す
        abort(403, 'CSRFエラー: Originが一致しません。');
    }

    /**
     * データを読み取るのみで変更するのことないリクエストか否か
     * @param  Request  $request
     * @return bool
     */
    protected function isReading(Request $request): bool
    {
        return in_array($request->method(), ['HEAD', 'GET', 'OPTIONS']);
    }

    /**
     * アプリケーションがテスト中か否か
     * @return bool
     */
    protected function runningTests(): bool
    {
        // runningUnitTestsとあるが、実際はテスト環境か否かのチェックのみがされている
        return app()->runningInConsole() && app()->runningUnitTests();
    }

    /**
     * $requestのオリジンが許可リストに含まれているか否か。
     * .envファイルのAPP_URLから構築されたオリジン、及び設定ファイルに定義された追加のオリジンを許可リストとして使用する。
     * @param  Request  $request
     * @return bool
     */
    protected function isOriginAllowed(Request $request): bool
    {
        $origin = $request->headers->get('Origin');
        // .envのAPP_URLからOriginを作る
        $appUrl    = config('app.url');
        $appOrigin = parse_url($appUrl, PHP_URL_SCHEME) . '://' . parse_url($appUrl, PHP_URL_HOST);
        $port      = parse_url($appUrl, PHP_URL_PORT);
        if($port) {
            $appOrigin .= ':' . $port;
        }
        // APP_URL以外に許可するOriginがあればここで追加する
        $allowedOrigins = [
            $appOrigin,
            // config/app.php に次のように記載したURLも許可する
            // 'allowed_origins' => [
            //     'https://example.com',
            // ],
            ...config('app.allowed_origins', [])
        ];
        // 渡されたOriginが許可したOriginに含まれているかどうかを判定する
        return in_array($origin, $allowedOrigins, true);
    }
}

 Laravel10以下の場合は次のように app/Http/Kernel.php を書き換えればCSRF対策がトークンからOriginになります。

    protected $middlewareGroups = [
        'web' => [
            \App\Http\Middleware\EncryptCookies::class,
            \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
            \Illuminate\Session\Middleware\StartSession::class,
            \Illuminate\View\Middleware\ShareErrorsFromSession::class,
            // VerifyCsrfTokenが使われないようにして
            // \App\Http\Middleware\VerifyCsrfToken::class,
            // VerifyOriginが使われるようにします
            \App\Http\Middleware\VerifyOrigin::class,
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],

        'api' => [
            // \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
            \Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],
    ];

 Laravel11の場合は次のように bootstrap/app.php を書き換えればCSRF対策がトークンからOriginになります。

<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware) {
        // トークンによるCSRF対策が使われないようにして
        $middleware->removeFromGroup('web',\Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class);
        // オリジンによるCSRF対策が使われるようにします
        $middleware->appendToGroup('web', \App\Http\Middleware\VerifyOrigin::class);
        // Laravel11では次のように$middleware->getMiddlewareGroups()をdumpすると、この段階でのミドルウェアの構成を確認できます
        // dump($middleware->getMiddlewareGroups());
        // array:2 [▼
        //  "web" => array:6 [▼
        //    0 => "Illuminate\Cookie\Middleware\EncryptCookies"
        //    1 => "Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse"
        //    2 => "Illuminate\Session\Middleware\StartSession"
        //    3 => "Illuminate\View\Middleware\ShareErrorsFromSession"
        //    4 => "Illuminate\Routing\Middleware\SubstituteBindings"
        //    5 => "App\Http\Middleware\VerifyOrigin"
        //  ]
        //  "api" => array:1 [▼
        //    0 => "Illuminate\Routing\Middleware\SubstituteBindings"
        //  ]
        //]
    })
    ->withExceptions(function (Exceptions $exceptions) {
        //
    })->create();

 こんな感じでLaravelのCSRF対策を備え付けのトークンによる対策からOriginヘッダーを用いた対策に切り替えられます。こうすると「普通にログインしようとしただけなのにエラーになった」など不便が起きなくなります。

 ちなみに完全にCSRFトークンをなくすのではなく、Origin検証かCSRFトークン検証にパスすれば正常なリクエストとみなす形式にしたい場合は次のようにできます。

<?php

namespace App\Http\Middleware;

use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
use Illuminate\Http\Request;
use Illuminate\Session\TokenMismatchException;

class VerifyOriginOrCsrfToken extends Middleware
{
    /**
     * Handle an incoming request.
     *
     * @param  Request                $request
     * @param  \Closure               $next
     * @throws TokenMismatchException
     * @return mixed
     */
    public function handle($request, \Closure $next)
    {
        // POST等でOriginが送られてきた時だけチェック
        $origin = $request->headers->get('Origin');
        if ($origin && $this->isOriginAllowed($origin)) {
            return $next($request);
        }
        // Origin検証が通らなかったら従来のCSRFトークン検証に移行
        return parent::handle($request, $next);
    }

    /**
     * $requestのオリジンが許可リストに含まれているか否か。
     * .envファイルのAPP_URLから構築されたオリジン、及び設定ファイルに定義された追加のオリジンを許可リストとして使用する。
     * @param  string $origin
     * @return bool
     */
    protected function isOriginAllowed(string $origin): bool
    {
        // .envのAPP_URLからOriginを作る
        $appUrl    = config('app.url');
        $appOrigin = parse_url($appUrl, PHP_URL_SCHEME) . '://' . parse_url($appUrl, PHP_URL_HOST);
        $port      = parse_url($appUrl, PHP_URL_PORT);
        if($port) {
            $appOrigin .= ':' . $port;
        }
        // APP_URL以外に許可するOriginがあればここで追加する
        $allowedOrigins = [
            $appOrigin,
            // config/app.php に次のように記載したURLも許可する
            // 'allowed_origins' => [
            //     'https://example.com',
            // ],
            ...config('app.allowed_origins', [])
        ];
        // 渡されたOriginが許可したOriginに含まれているかどうかを判定する
        return in_array($origin, $allowedOrigins, true);
    }
}
>株式会社シーポイントラボ

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

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

CTR IMG