🐘

LaravelはどのようにCSRF対策をしているのか?

2024/10/19に公開

誰しもLaravelのbladeformを書くにあたって、@csrfという魔法の呪文を書いたことがあるかと思います。

「これを書いておけばCSRF対策はOK」

ドキュメントにも要約するとそういう旨が書いてあります。

https://readouble.com/laravel/8.x/ja/csrf.html

この記事では@csrfについてLaravelの実装を実際に見てみることで、CSRFとその対策への理解を深めたいと思います。

ちなみにこの記事はぺちこん2024で残念ながら採択に至らなかったCfPの供養です。[1]

利用するサンプルアプリ

https://github.com/ysknsid25/otaku-tool

@csrf はなにをしているのか?

そもそもですが、@csrfが何をしているのかを見てみます。

blade@csrfを埋め込んだ場所を、HTML変換後の状態から見てみます。

<input type="hidden" name="_token" value="G5FzKXaCYA4w8kdWbftEZMYoglQgD9yPIG9r2zzx" autocomplete="off">

ドキュメントによると、@csrfはこのように変換されるようです。

<form method="POST" action="/profile">
    @csrf
 
    <!-- Equivalent to... -->
    <input type="hidden" name="_token" value="{{ csrf_token() }}" />
</form>

つまり、csrf_token()という関数を呼んでいるらしい。

csrf_tokenの中身を見てみる

この関数は、framework/vendor/laravel/framework/src/Illuminate/Foundation/helpers.phpに定義されてるようです。

framework/vendor/laravel/framework/src/Illuminate/Foundation/helpers.php
if (! function_exists('csrf_token')) {
    /**
     * Get the CSRF token value.
     *
     * @return string
     *
     * @throws \RuntimeException
     */
    function csrf_token()
    {
        $session = app('session');

        if (isset($session)) {
            return $session->token();
        }

        throw new RuntimeException('Application session store not set.');
    }
}

セッションがあれば、セッションからトークンを取り出すようです。

このことを頭に入れつつ次へ。

じゃ、トークンって何?

「セッションの中にあるということは、ログイン時に生成してるっぽい?」

というあたりをつけて、framework/app/Http/Controllers/Auth/AuthenticatedSessionController.phpをみます。

framework/app/Http/Controllers/Auth/AuthenticatedSessionController.php
    /**
     * Handle an incoming authentication request.
     */
    public function store(LoginRequest $request): RedirectResponse
    {
        $request->authenticate();

        $request->session()->regenerate();

        return redirect()->intended(RouteServiceProvider::HOME);
    }

regenerateを見てみます。

framework/vendor/laravel/framework/src/Illuminate/Session/Store.php
    /**
     * Generate a new session identifier.
     *
     * @param  bool  $destroy
     * @return bool
     */
    public function regenerate($destroy = false)
    {
        return tap($this->migrate($destroy), function () {
            $this->regenerateToken();
        });
    }

    /**
     * Regenerate the CSRF token value.
     *
     * @return void
     */
    public function regenerateToken()
    {
        $this->put('_token', Str::random(40));
    }

$this->put('_token', Str::random(40));というぽいやつがあるなということで、ファイルに保存しているセッション情報を見に行きます。

framework/storage/framework/sessions/EDU9V4zsRPFck9eIMQVry6tUCsIwboMld6f5DDr8
a:4:{s:6:"_token";s:40:"G5FzKXaCYA4w8kdWbftEZMYoglQgD9yPIG9r2zzx";s:9:"_previous";a:1:{s:3:"url";s:29:"http://127.0.0.1:8085/profile";}s:6:"_flash";a:2:{s:3:"old";a:0:{}s:3:"new";a:0:{}}s:50:"login_web_59ba36addc2b2f9401580f014c7f58ea4e30989d";i:1;}

G5FzKXaCYA4w8kdWbftEZMYoglQgD9yPIG9r2zzxが生成されたトークンらしく、セッション情報の中に保存されたトークンが@csrfで埋め込まれたhiddenの値として埋め込まれているようです。

ではLaravelはどのようにトークン検証しているか?

これは想像に難くないかと思います。こういうことは、だいたいMiddlewareがやっています。

framework/app/Http/Kernel.phpを見てみる。

framework/app/Http/Kernel.php
    /**
     * The application's route middleware groups.
     *
     * @var array<string, array<int, class-string|string>>
     */
    protected $middlewareGroups = [
        '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,
        ],

VerifyCsrfTokenとかいうぽいやつがいますね。MiddlewareはVerifyCsrfTokenhandleが本体。

見てみると、ぽいぽい。

framework/app/Http/Kernel.php
   /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     *
     * @throws \Illuminate\Session\TokenMismatchException
     */
    public function handle($request, Closure $next)
    {
        if (
            $this->isReading($request) ||
            $this->runningUnitTests() ||
            $this->inExceptArray($request) ||
            $this->tokensMatch($request)
        ) {
            return tap($next($request), function ($response) use ($request) {
                if ($this->shouldAddXsrfTokenCookie()) {
                    $this->addCookieToResponse($request, $response);
                }
            });
        }

        throw new TokenMismatchException('CSRF token mismatch.');
    }

CSRF攻撃でないと判定するための条件を見ていくと…

framework/app/Http/Kernel.php
    /**
     * Determine if the HTTP request uses a ‘read’ verb.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return bool
     */
    protected function isReading($request)
    {
        return in_array($request->method(), ['HEAD', 'GET', 'OPTIONS']);
    }

    /**
     * Determine if the application is running unit tests.
     *
     * @return bool
     */
    protected function runningUnitTests()
    {
        return $this->app->runningInConsole() && $this->app->runningUnitTests();
    }

    /**
     * Determine if the request has a URI that should pass through CSRF verification.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return bool
     */
    protected function inExceptArray($request)
    {
        foreach ($this->except as $except) {
            if ($except !== '/') {
                $except = trim($except, '/');
            }

            if ($request->fullUrlIs($except) || $request->is($except)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Determine if the session and input CSRF tokens match.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return bool
     */
    protected function tokensMatch($request)
    {
        $token = $this->getTokenFromRequest($request);

        return is_string($request->session()->token()) &&
               is_string($token) &&
               hash_equals($request->session()->token(), $token);
    }

前半3つはCSRF対策から除外するパターンに該当するかの判定ですね。inExceptArrayのメソッドとかはなるほどって感じです。

ということで肝になるのはtokensMatchで、これで送られてきたトークンとサーバー側に保存したトークンが一致するかどうか?で、正しいリクエストかどうかを判定しています。

おわりに

自分は文章で読んだりすると事象と対策を全然覚えられないけどこうして実装を見てみると覚えられたので、他のセキュリティ対策に関してもいろんなソースを読んでみるといいのかもと思いました。

脚注
  1. 仕事でPHPを書くことがなくなったおかげで、PHP関係はしばらく登壇も記事執筆もしなくなる予定 ↩︎

Discussion