LaravelはどのようにCSRF対策をしているのか?
誰しもLaravelのblade
でform
を書くにあたって、@csrf
という魔法の呪文を書いたことがあるかと思います。
「これを書いておけばCSRF対策はOK」
ドキュメントにも要約するとそういう旨が書いてあります。
この記事では@csrf
についてLaravelの実装を実際に見てみることで、CSRFとその対策への理解を深めたいと思います。
ちなみにこの記事はぺちこん2024で残念ながら採択に至らなかったCfPの供養です。[1]
利用するサンプルアプリ
@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
に定義されてるようです。
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
をみます。
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
return redirect()->intended(RouteServiceProvider::HOME);
}
regenerate
を見てみます。
/**
* 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));
というぽいやつがあるなということで、ファイルに保存しているセッション情報を見に行きます。
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
を見てみる。
/**
* 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はVerifyCsrfToken
のhandle
が本体。
見てみると、ぽいぽい。
/**
* 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攻撃でないと判定するための条件を見ていくと…
/**
* 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
で、これで送られてきたトークンとサーバー側に保存したトークンが一致するかどうか?で、正しいリクエストかどうかを判定しています。
おわりに
自分は文章で読んだりすると事象と対策を全然覚えられないけどこうして実装を見てみると覚えられたので、他のセキュリティ対策に関してもいろんなソースを読んでみるといいのかもと思いました。
-
仕事でPHPを書くことがなくなったおかげで、PHP関係はしばらく登壇も記事執筆もしなくなる予定 ↩︎
Discussion