Laravel 底层原理 - CSRF
TokenMismatchException
👋
你可能至少遇到过一次这种情况。你复制粘贴了异常,在网上搜索了一下,发现在请求中添加 @csrf
之类的指令或在 header 中引入 X-CSRF-TOKEN
就是修复方法。我们都走过这条路。但你有没有想过,为什么 Laravel 一开始就抛出这个异常?你真的需要在每个请求中发送一个 token 吗?是的。否则,他们会开玩笑说 PHP 不安全😒. 为了做到这一点,让我们退一步来谈谈一个古老但仍然存在的漏洞,称为 CSRF。
也就是,跨站点请求伪造?
假设你是一个迷恋一个女孩的高中生。捕获?你很害羞,希望她能给你发个朋友请求。那不是很酷吗?好吧,让我们让它发挥作用😈
任务:让她在没有意识到的情况下表现得像你一样。
所以,你可以创建一个简单的网页,上面有可爱的狗狗照片(或者她喜欢的任何东西)。当她加载此页面时,会在后台触发一个请求,此请求是由你精心编制的。例如,它可以是对端点的 POST 请求,该请求将有效负载中给定的内容添加为好友。在我们的例子中,你会要求添加自己为她的朋友(我不想这么说,但你必须使用 JavaScript)。偷偷地,你会分享这个链接,也许是通过她的朋友,或者,好吧,这部分由你决定😛. 她收到链接,点击它,代码执行,然后使用她的 cookie和活动会话将请求发送到服务器。之所以会发生这种情况,是因为 cookie 会随每一个请求自动发送,这就是导致该漏洞的原因。如果她已经登录(很可能),她会在不知情的情况下向你发送朋友请求。 这就是 CSRF 在行动。
请注意,我们可以设置了某些 cookie 安全标志来防止这种行为,但我们现在不讨论它们。
总之,你利用了受害者(在本例中是女孩)正在进行的会话。你欺骗他们加载一个网页或点击一个按钮,通过他们的会话向服务器发送一个由你精心制作的请求。这允许你在他们的账户上执行任何你想要的操作,比如删除、点赞你自己的帖子,或者可能更糟糕的情况。。
Laravel 如何解决这一问题?
让我们首先讨论解决方案概念,然后深入了解 Laravel 的实现。在我们的场景中,一切都安排好了,因为这个家伙拥有制造有效载荷所需的一切。如果有一个随机生成的 token 与女孩的 session 绑定,无法猜测或破解,这是可以避免的。为什么?因为此 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,
],
这些表示应用于所有 web 路由的中间件。我们的重点在于 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()
:这会检查应用是否在控制台中运行,并且请求是从测试中发出的,没有绝对的必要验证令牌(假设您编写了测试👀);inExceptArray()
:还记得我们之前研究过的VerifyCsrfToken
中间件中的$except
数组吗?这会检查当前路由是否在数组中定义,并且应该从令牌验证中排除(除非你知道自己在做什么,否则不要滥用);最后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
中检查它。现在,你可能会对这个 header 感到好奇。这主要是为了方便开发人员。Laravel 在每个响应中都会发回一个名为 XSRF-TOKEN
的 cookie。有些库在发出请求时,会在发出的每个请求上自动将此 cookie 的值设置为 X-XSRF-TOKEN
标头。让我们也检查一下这个请求是由 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 session 中的值进行比较。让我们转到 storage/framework/sessions
(假定你未修改默认的 session 配置),你能在此找到用户 session。检查任何一个文件:
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:{}}}
它是一个封装了 session 的序列化 PHP 对象,请留意 _token
字段。
因此,在密钥 _token
下存储在用户 session 中的内容都必须与任何写入请求中提供的令牌相匹配。如果不是,Laravel 将抛出 TokenMismatchException
异常。
现在你可能想知道,“我什么时候发送这个令牌的?”好吧,当你遇到这个异常时,解决方案涉及添加 @csrf
指令,对吗?此指令在 HTML 表单中嵌入一个具有正确 token 值的隐藏字段。
我们要不要进一步探讨一下?转到 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
的输入字段拥有要在 session 中设置的值。如果这些值在 tokensMatch()
方法中匹配(现在你知道为什么了),如果请求是真实的,它们也会匹配,Laravel 会很高兴,否则,他会抛出异常。