Laravel под капотом: CSRF

Источник: «Laravel Under The Hood - CSRF»
Не раз и не два мы сталкивались с печально известным исключением 'Token Mismatch' в Laravel. Но, бедняга просто заботится о вас, пытаясь защитить от зла 😈, давайте посмотрим...

Привет, TokenMismatchException 👋

Я знаю, что вы наверняка сталкивались с этим хотя бы раз. Вы копировали исключение, немного погуглили и выяснили, что добавление директивы @csrf или включение заголовка X-CSRF-TOKEN в запрос является решением проблемы. Мы все проходили этот путь. Но задумывались ли вы, почему Laravel вообще выбрасывает это исключение? Действительно ли вам нужно отправлять токен с каждым запросом? Ну, да, ДА, нужно. Иначе начнут шутить, что PHP небезопасен 😒. Чтобы понять это, давайте сделаем шаг назад и поговорим о старой, но все ещё существующей уязвимости под названием CSRF.

Итак, подделка межсайтовых запросов

Посмотрите на меня, у меня страшное имя 👻, но я довольно прост.

Представьте, что вы — старшеклассник, сильно влюблённый в девушку. Но в чем загвоздка? Вы стесняетесь и хотите, чтобы она послала запрос на дружбу именно вам. Разве это не было бы круто? Что ж, давайте сделаем это 😈.

Задача: Заставьте её вести себя так, как будто она — это вы, не осознавая этого.

Итак, вы создаёте простую веб-страницу с фотографиями милых собачек (или того, что ей нравится). Когда она загружает эту страницу, в фоновом режиме срабатывает запрос, составленный вами. Например, это может быть POST-запрос к конечной точке, которая добавляет в друзья все, что указано в полезной нагрузке. В нашем случае вы попросите добавить себя в друзья (не хочется говорить об этом, но придётся использовать JavaScript). Незаметно вы поделитесь этой ссылкой, возможно, через её друзей или, ну, это уже на ваше усмотрение 😛. Она получает ссылку, нажимает на неё, код исполняется, и запрос отправляется на сервер, используя её куки и её активную сессию. Это происходит потому, что куки автоматически отправляются с каждым запросом, и именно это обеспечивает уязвимость. Если она вошла в систему (скорее всего), она неосознанно отправляет вам запрос на дружбу. И это, мой друг, CSRF в действии.

Обратите внимание, что существуют определённые флаги безопасности куки, которые мы устанавливаем для предотвращения такого поведения, но сейчас мы не будем о них говорить.

В общем, вы используете текущую сессию жертвы (в данном случае девушки). Вы обманом заставляете её загрузить веб-страницу или нажать на кнопку, которая отправляет запрос, составленный вами, на сервер, использующий её сессию. Это позволяет вам выполнить любое действие над её учётной записью, такое как удаление, лайк собственных сообщений или потенциально даже худшие сценарии…

И как Laravel решает эту проблему

Давайте сначала обсудим концепцию решения, а затем погрузимся в реализацию Laravel. В нашем сценарии все встало на свои места, потому что у парня было все необходимое для создания полезной нагрузки. Этого можно было бы избежать, если бы к сессии девушки был привязан случайно сгенерированный токен, который нельзя было бы угадать или взломать. Почему? Потому что этот токен зашифрован с помощью ключа, хранящегося только на сервере. В этом и заключается суть решения: любой запрос, выполняющий важную задачу, например добавление друга, должен содержать токен, чтобы убедиться, что настоящий пользователь по собственной воле инициировал это действие. Например, если кто-то попытается выполнить запрос, пока вы любуетесь фотографиями милых собачек, сервер не разрешит его, потому что хакер никак не может догадаться и предоставить токен в своём подстроенном запросе.

Чтобы изучить, как Laravel реализует это, давайте перейдём к файлу app/Http/Kernel.php

'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,
],

Они представляют собой middleware, применяемые ко всем веб-маршрутам. Наше внимание сосредоточено на VerifyCsrfToken. Давайте рассмотрим его подробнее

<?php

namespace App\Http\Middleware;

use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;

class VerifyCsrfToken extends Middleware
{
/**
* The URIs that should be excluded from CSRF verification.
*
* @var array<int, string>
*/

protected $except = [
//
];
}

Ничего особенного, поэтому следующим направлением будет родительский класс VerifyCsrfToken

<?php

namespace Illuminate\Foundation\Http\Middleware;

use Closure;
use Illuminate\Session\TokenMismatchException;

class VerifyCsrfToken
{
/**
* 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.');
}

// больше кода
}

Как и в любом другом middleware, нас интересует метод handle(). Вы можете видеть, что выполняется несколько проверок. Если любая из этих проверок пройдена, Laravel доволен:

<?php

namespace Illuminate\Foundation\Http\Middleware;

use Illuminate\Http\Request;

class VerifyCsrfToken
{
protected function tokensMatch(Request $request): bool
{
$token = $this->getTokenFromRequest($request);

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

// больше кода
}

Таким образом, Laravel пытается получить токен из запроса. Давайте рассмотрим это подробнее

<?php

namespace Illuminate\Foundation\Http\Middleware;

use Illuminate\Http\Request;
use Illuminate\Cookie\CookieValuePrefix;
use Illuminate\Contracts\Encryption\Encrypter;
use Illuminate\Contracts\Encryption\DecryptException;

class VerifyCsrfToken
{
protected Encrypter $encrypter;

protected function getTokenFromRequest(Request $request): string
{
$token = $request->input('_token') ?: $request->header('X-CSRF-TOKEN');

if (! $token && $header = $request->header('X-XSRF-TOKEN')) {
try {
$token = CookieValuePrefix::remove($this->encrypter->decrypt($header, static::serialized()));
} catch (DecryptException) {
$token = '';
}
}

return $token;
}

// больше кода
}

Laravel пытается получить токен из поля с именем _token для любого запроса на запись, обычно связанного с отправкой обычной формы. Если это поле не найдено, он ищет токен в заголовке X-CSRF-TOKEN, используемом для AJAX-запросов.

Если токен не установлен, Laravel проверяет его наличие в X-XSRF-TOKEN. Теперь вы можете задаться вопросом об этом заголовке. Он предназначен в первую очередь для удобства разработчиков. Laravel отправляет куки с именем XSRF-TOKEN в каждом ответе. Некоторые библиотеки при выполнении запросов автоматически устанавливают значение этого cookie в заголовок X-XSRF-TOKEN при каждом запросе. По сути, Laravel говорит: давайте проверим, не сделан ли этот запрос Axios или другими JS-библиотеками. В итоге возвращается $token.

Вернёмся к методу tokensMatch()

<?php

namespace Illuminate\Foundation\Http\Middleware;

use Illuminate\Http\Request;

class VerifyCsrfToken
{
protected function tokensMatch(Request $request): bool
{
$token = $this->getTokenFromRequest($request);

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

// больше кода
}

Значение $token, полученное из запроса, сравнивается с тем, что хранится в сессии Laravel. Давайте перейдём в storage/framework/sessions (при условии, что вы не трогали стандартный конфиг сессии), там вы найдёте пользовательские сессии. Просмотрите любой из этих файлов

a:3:{s:6:"_token";s:40:"bi2fA9ienYF09b5Ny3ovCvUR5NpStGkPAMDWOFg7";s:9:"_previous";a:1:{s:3:"url";s:21:"http://localhost";}s:6:"_flash";a:2:{s:3:"old";a:0:{}s:3:"new";a:0:{}}}

Сериализованный PHP объект, содержащий сессию, и обратите внимание на поле _token.

Таким образом, все, что хранится в сессии пользователя под ключом _token, должно совпадать с токеном, предоставленным в любом запросе на запись. В противном случае Laravel выбросит исключение TokenMismatchException.

Теперь вы можете задаться вопросом: Когда я вообще отправил этот токен?. Ну, когда вы столкнулись с этим исключением, решение заключалось в добавлении директивы @csrf, верно? Эта директива встраивает скрытое поле в вашу HTML-форму с правильным значением токена.

Продолжим изучение этого вопроса? Перейдём к Illuminate\View\Compilers\Concerns\CompilesHelpers

<?php

namespace Illuminate\View\Compilers\Concerns;

trait CompilesHelpers
{
protected function compileCsrf(): string
{
return '<?php echo csrf_field(); ?>';
}

// здесь дополнительный кода
}

Результат работы этого метода заменит директиву @csrf. Заглянув в функцию csrf_field(), расположенную в файле helpers.php, мы обнаружим следующий фрагмент кода

function csrf_field()
{
return new HtmlString('<input type="hidden" name="_token" value="'.csrf_token().'" autocomplete="off">');
}

Выглядит знакомо? Это скрытое поле с именем _token, о котором я вам рассказывал. Именно поэтому метод getTokenFromRequest() ищет ключ _token. Теперь давайте закрепим все, о чем мы говорили в этой статье, рассмотрев функцию csrf_token()

function csrf_token()
{
$session = app('session');

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

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

// больше кода
}

Обратите внимание, что функция возвращает все, что находится в $session->token() (хотя мы не будем погружаться в этот код, иначе нам придётся обсуждать паттерн менеджера Laravel 😛). Поле ввода с именем _token, отправляемое вместе с запросом, имеет точное значение, установленное в сессии. Если эти значения совпадут в методе tokensMatch() (а теперь вы знаете, почему), что произойдёт, если запрос подлинный, Laravel будет счастлив, в противном случае он выбросит исключение.

Вот и все! Вы собрали все части!

Конец

Эй, вы дошли до этого 🎉. Посмотрите на себя, теперь вы хорошо разбираетесь во внутреннем устройстве Laravel, а также знаете о веб уязвимостях! В следующий раз, когда Laravel выбросит исключение, не злитесь, это для вашего же блага!

Я подумываю о том, чтобы написать больше о внутреннем устройстве Laravel, как вы думаете, это хорошая идея? Я с удовольствием выслушаю ваши мысли. Не стесняйтесь связаться со мной на любой из перечисленных ниже платформ!

Дополнительные материалы

Предыдущая Статья

Совет по безопасности: Экранирование с e(), htmlspecialchars() и htmlentities()

Следующая Статья

Версионирование API в Laravel: Как сделать это правильно