支持 Laravel.io 的持续开发 →

Laravel 源代码解析 - CSRF

2024年2月14日 阅读时间:8分钟

你好,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读取动词(HEADGETOPTIONS),这就是为什么当进行这些请求之一时,你从未遇到过这个问题;
  • 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内部机制的内容,你认为这是一个好主意吗?我很想听听你的想法。请随时在以下任一平台上联系我!

最后更新于5个月前。

driesvints, ol-serdiuk 赞同了这篇文章

2
喜欢这篇文章?让作者知道并给他们一个点赞!
oussamamater (Oussama Mater) 我是一个软件工程师和CTF玩家。我使用Laravel和Vue.js将想法变为应用程序 🚀

你可能还喜欢的其他文章

2024年3月11日

如何使用 Larastan 从 0 到 9 级提升你的 Laravel 应用

在 Laravel 应用执行之前就能找到错误,这要归功于 Larastan,它...

阅读文章
2024年7月19日

无需 traits 也能统一 API 响应

我发现大多数用于 API 响应的库都使用 traits 实现...

阅读文章
2024年7月17日

在你的 Laravel 项目中通过 Discord 通知收集反馈

如何在 Laravel 项目中创建反馈模块并在收到消息时收到 Discord 通知...

阅读文章

我们想感谢这些 令人惊叹的公司 支持我们

你公司的标志在这里?

Laravel.io

Laravel 问题解决、知识共享和社区建设的门户

© 2024 Laravel.io - 版权所有。