编程

Laravel 底层原理 - CSRF

100 2024-04-20 06:43:00

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 读取动词(HEADGETOPTIONS),这就是为什么你在发出这些请求时从未遇到过这个问题;
  • 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 会很高兴,否则,他会抛出异常。