Загрузка...

Livewire и безопасность: три уязвимости, которые легко пропустить

laravel cover

В прошлой статье разобрали IDOR — когда пользователь подменяет ID и получает чужую запись. Сегодня — три менее очевидных проблемы, которые встречаются даже в хорошо написанном коде.


1. #[Locked] — публичное свойство как дыра в безопасности

В Livewire 3 каждое public свойство компонента синхронизируется с браузером. Это значит, что любой авторизованный пользователь может отправить:

// Прямо из DevTools консоли браузера
Livewire.find('component-id').set('editingId', 999)

И у тебя на сервере $this->editingId станет 999. Даже если в шаблоне нет никакого wire:model="editingId".

Типичный уязвимый компонент:

class WarehousePage extends Component
{
    use AuthorizesRequests;

    // ❌ Доступно для записи из JS
    public ?int $editingId = null;

    public function saveWarehouse(): void
    {
        if ($this->editingId) {
            $warehouse = Warehouse::findOrFail($this->editingId);
            $this->authorize('update', $warehouse);
            $warehouse->update(['name' => $this->name]);
        }
    }
}

Что произойдёт:

  1. Пользователь открывает страницу складов
  2. Через DevTools ставит editingId = 999 (склад другой компании)
  3. Отправляет форму с новыми данными
  4. Метод saveWarehouse() найдёт склад 999 и попытается обновить

Да, authorize('update', $warehouse) защитит — если Policy настроена корректно. Но это не значит, что подмену данных надо допускать.

Решение — атрибут #[Locked]:

use Livewire\Attributes\Locked;

class WarehousePage extends Component
{
    // ✅ Нельзя изменить из JS — Livewire выбросит исключение
    #[Locked]
    public ?int $editingId = null;
}

Теперь при попытке записать editingId снаружи Livewire сам вернёт ошибку, даже не доходя до твоего кода.

Правило простое: всё, что пользователь не должен менять через wire:model — ставь #[Locked]. Типичные кандидаты: $editingId$familyId$ownerId$isAdmin, любые серверные флаги.


2. mount() vs boot() — где на самом деле работает защита

Это одна из самых распространённых ошибок при написании Livewire-компонентов.

// ❌ Выглядит правильно, но работает только при первой загрузке
public function mount(): void
{
    if (! Auth::user()->can('view.warehouses')) {
        abort(403);
    }
}

mount() вызывается один раз — когда компонент впервые рендерится на странице. После этого при каждом Ajax-запросе (клик, вызов метода, изменение свойства) mount() не вызывается.

Что это значит на практике:

  1. Пользователь заходит на страницу — mount() проверяет доступ, всё хорошо
  2. Администратор отзывает у пользователя permission view.warehouses
  3. Пользователь уже на странице — он продолжает вызывать методы компонента, потому что mount() больше не запускается
  4. Если методы мутации не защищены — данные под угрозой

Ещё хуже: в Livewire есть wire:navigate (SPA-режим). При нём компонент может не перемонтироваться при переходе между страницами. mount() и вовсе не вызовется.

Решение — boot():

// ✅ Вызывается при каждом запросе к компоненту
public function boot(): void
{
    if (! Auth::check() || ! Auth::user()->can('view.warehouses')) {
        throw new HttpResponseException(response('Отсутствует доступ', 403));
    }
}

// mount() оставляем только для инициализации данных
public function mount(): void
{
    $this->warehouses = Warehouse::all();
}

boot() запускается перед каждым действием в компоненте — при первом рендере, при вызове метода, при обновлении свойства.

Важно: используй HttpResponseException, а не abort() внутри Livewire. abort() в контексте Ajax-запроса Livewire может вести себя непредсказуемо в зависимости от версии.


3. catch (\Exception) — тихое убийство авторизации

Это самая коварная проблема. Выглядит как нормальная обработка ошибок, но фактически ломает всю защиту.

// ❌ Выглядит безобидно
public function saveWarehouse(): void
{
    $this->validate();

    try {
        $warehouse = Warehouse::findOrFail($this->editingId);
        $this->authorize('update', $warehouse); // бросает AuthorizationException
        $warehouse->update(['name' => $this->name]);

    } catch (\Exception $e) {
        // AuthorizationException — наследник Exception
        // Попадёт сюда и будет проглочена
        $this->dispatch('notify', message: 'Ошибка', type: 'error');
    }
}

AuthorizationException — это наследник \Exception. Когда authorize() отказывает, оно бросает это исключение. Если у тебя стоит catch (\Exception) — авторизация проглатывается, пользователь получает «Ошибка» вместо 403, и… мутация скорее всего всё равно не произошла. Но это хрупко — зависит от порядка кода.

Ещё хуже, если код выглядит так:

try {
    $warehouse = Warehouse::findOrFail($this->editingId);
    $warehouse->update(['name' => $this->name]); // ← сначала update
    $this->authorize('update', $warehouse); // ← потом authorize (антипаттерн!)
} catch (\Exception $e) {
    // update уже произошёл, authorize упал, всё проглочено
}

Правильный подход — два раздельных catch:

public function saveWarehouse(): void
{
    try {
        $warehouse = Warehouse::findOrFail($this->editingId);
        $this->authorize('update', $warehouse); // ← сначала authorize
        $warehouse->update(['name' => $this->name]); // ← потом мутация

    } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
        throw $e; // ← перебрасываем, Livewire вернёт 403
    } catch (\Exception $e) {
        $this->dispatch('notify', message: 'Ошибка: ' . $e->getMessage(), type: 'error');
    }
}

Или — если хочешь совсем чисто — убери try/catch вокруг мутации и ловить только специфические исключения:

public function saveWarehouse(): void
{
    $warehouse = Warehouse::findOrFail($this->editingId);
    $this->authorize('update', $warehouse);

    try {
        $warehouse->update(['name' => $this->name]);
        $this->dispatch('notify', message: 'Сохранено', type: 'success');
    } catch (\Exception $e) {
        $this->dispatch('notify', message: 'Ошибка БД: ' . $e->getMessage(), type: 'error');
    }
}

Итог

Три дополнительных правила для безопасного Livewire:

#[Locked] — любое публичное свойство, которое пользователь не редактирует через wire:model, должно быть заблокировано. Особенно ID-шники и флаги.

boot() вместо mount() — проверки доступа к компоненту (view-авторизация) должны быть в boot(), а не в mount()mount() — только для инициализации данных.

Не глотай AuthorizationException — если оборачиваешь мутации в try/catch (\Exception), всегда перебрасывай авторизационное исключение или выноси authorize() за пределы try.

Чек-лист в дополнение к предыдущей статье:

  • ✅ #[Locked] на всех $editingId$selectedId, серверных флагах
  • ✅ View-авторизация в boot(), а не в mount()
  • ✅ authorize() вызывается до любой мутации (findOrFail → authorize → update)
  • ✅ catch (\Illuminate\Auth\Access\AuthorizationException $e) перебрасывается явно
  • ✅ mount() содержит только инициализацию данных

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *