你好,TokenMismatchException
👋
我知道你可能至少遇到过一次这个问题。你复制粘贴异常,做了一些搜索,发现添加指令@csrf
或在请求中包含头X-CSRF-TOKEN
可以解决问题。我们都走过这条路。但你有没有想过为什么 Laravel 会抛出这个异常呢?你真的需要在每个请求中发送令牌吗?嗯,是的,你必须这样做。否则,他们就会开始拿 PHP 不够安全这件事开玩笑 😒。为了弄清楚这个问题,让我们回顾一下一个古老但仍存在的漏洞——跨站请求伪造(CSRF)。
那么,什么是跨站请求伪造(CSRF)呢?
看看我,名字听起来很吓人 👻,但其实它很简单。
想象一下,你是一位高中学生,非常喜欢一个女孩。问题是,你很害羞,希望她能给你发一个好友请求。这不酷吗?那么,让我们试试吧 😈
任务:让她在你不知情的情况下,假装是你。
因此,你会创建一个简单的网页,上面展示可爱的狗狗照片(或者她喜欢的东西)。当她加载这个页面时,会触发一个在后台运行的需求,这个需求是由你精心制作的。例如,这可能是一个要发送到添加朋友端点的POST请求,将有效负载中的任何内容添加为朋友。在我们的案例中,你会请求将她添加为你(我不想这么说,但我们必须使用JavaScript)。狡猾地分享这个链接,也许通过她的朋友或者,那个部分的解决方案取决于你自己😈。她收到链接,点击它,代码执行,并使用她的cookie和她的活动会话将请求发送到服务器。这是因为cookie会自动与每个请求一起发送,这就是利用漏洞的原因。如果她已经登录(可能性很大),她会不知不觉地发送给你那个朋友请求。我的朋友,这就是CSRF在起作用。
请注意,我们设置了一些cookie安全标志来阻止这种行为,但现在我们不会讨论它们。
总的来说,你利用了受害者(在这个案例中,是那个女孩)的持续会话。你诱骗他们加载一个网页或点击一个发送请求的按钮,该请求是你精心制作的。使用他们的会话将请求发送到服务器。这允许你在他们的账户上执行任何你想要执行的操作,例如删除,喜欢自己的帖子,或者可能是更糟糕的情况..
那么Laravel是如何解决这个问题的呢?
首先让我们讨论一下解决方案的概念,然后深入探讨Laravel的实现。在我们这个场景中,一切都在预料之内,因为那个家伙有制作问题描述所需的一切。如果有随机生成的与女孩的会话相关的token,不能被猜测或破解,这可以阻止这种情况的发生。为什么?因为此token是用存储在服务器上的唯一密钥加密的。这是解决方案的本质:执行重大任务(如添加朋友)的任何请求都应该携带token,以确保真正的用户自愿发起操作。例如,如果在你看这些可爱的狗狗照片时有人尝试进行请求,服务器将不会授权它,因为没有黑客能够猜测并提供token到他们制作的请求中。
为了探索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, // <- this guy
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
这些代表所有网络路由应用中间件。我们的重点是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.');
}
// more code
}
就像每个中间件一样,我们对handle()
方法感兴趣。你可以看到执行了多个检查。如果所有这些检查都失败,Laravel会抛出TokenMismatchException
异常。现在你知道异常是在哪里抛出的,让我们讨论一下检查。
-
isReading()
:这个检查请求是否使用了HTTP读取动词(HEAD
、GET
和OPTIONS
),这就是为什么当进行这些请求之一时,你从未遇到过这个问题; -
runningUnitTests()
:正如你可能已经猜到的,这个检查应用程序是否正在控制台运行,请求是否来自测试,在没有绝对必要验证token的情况下(假设你在写测试👀); -
inExceptArray()
:记得我们之前探索的VerifyCsrfToken
中间件中的$except
数组吗?这个检查当前路由是否定义在该数组中,并且应该豁免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);
}
// more code
}
因此,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;
}
// more code
}
Laravel试图从一个名为_token
的字段中获取令牌,该字段与任何写请求相关,通常与常规表单提交相关。如果没有找到该令牌,它会检查用于AJAX请求的头部X-CSRF-TOKEN
中的令牌。
如果令牌仍然未设置,Laravel会检查X-XSRF-TOKEN
。现在,你可能想知道这个头部的作用。它主要是为了开发人员的便利。Laravel会随每个响应发送一个名为XSRF-TOKEN
的cookie。一些库在发出请求时,会自动将此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);
}
// more code
}
从请求中检索到的$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(); ?>';
}
// more code here
}
此方法的结果将替换@csrf
指令。查看在helpers.php
文件中找到的csrf_field()
函数,我们会看到以下代码片段
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.');
// more code
}
注意这个函数返回的是在$session->token()
内的任意内容(我们不会深入这个代码,否则我们将不得不讨论Laravel的管理器模式😜)。与请求一起发送的名为_token
的输入字段,其值与会话中设置的确切值相同。如果这些值在tokensMatch()
方法中(现在你知道为什么)匹配,那么如果请求是真实的,Laravel会很高兴,否则它会抛出异常。
就是这样!你收集到了所有的碎片!
结论
嘿,你已经走到这里了🎉看看你,现在你已经精通Laravel内部机制,也更有意识于网络漏洞!下一次Laravel抛出异常时,不要生气,这对你是好的!
我在考虑写更多关于Laravel内部机制的内容,你认为这是一个好主意吗?我很想听听你的想法。请随时在以下任一平台上联系我!
driesvints, ol-serdiuk 赞同了这篇文章