В прошлой статье разобрали 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]);
}
}
}
Что произойдёт:
- Пользователь открывает страницу складов
- Через DevTools ставит
editingId = 999(склад другой компании) - Отправляет форму с новыми данными
- Метод
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() не вызывается.
Что это значит на практике:
- Пользователь заходит на страницу —
mount()проверяет доступ, всё хорошо - Администратор отзывает у пользователя permission
view.warehouses - Пользователь уже на странице — он продолжает вызывать методы компонента, потому что
mount()больше не запускается - Если методы мутации не защищены — данные под угрозой
Ещё хуже: в 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()содержит только инициализацию данных