Livewire — удобный инструмент. Ты пишешь PHP, а фреймворк сам превращает всё в Ajax-запросы. Но именно эта «магия» скрывает один неочевидный риск, про который редко пишут на русском.
Называется он IDOR — Insecure Direct Object Reference. Или по-простому: пользователь подменяет ID в запросе и получает доступ к чужим данным.
Что такое IDOR простыми словами
Представь: у тебя есть страница редактирования склада. Пользователь нажимает «Редактировать» — Livewire отправляет запрос с id=5. Компонент находит запись и открывает модалку.
Вопрос: а что если пользователь отправит id=1? Или id=999?
Если в компоненте нет проверки на уровне записи — он просто загрузит чужую запись. И позволит её изменить.
Почему Livewire особенно уязвим
В обычном Laravel-контроллере ты работаешь с HTTP-запросом. Middleware, route model binding, FormRequest — всё это привычные места для авторизации.
В Livewire всё иначе:
- Каждый
call('editWarehouse', 5)— это POST-запрос наlivewire/update - Параметры передаются в JSON-теле
- Любой авторизованный пользователь может вызвать любой публичный метод компонента с любыми аргументами прямо из DevTools
Вот реальный пример уязвимого кода:
// ❌ Опасно — нет проверки доступа к конкретной записи
#[On('editWarehouse')]
public function editWarehouse($id): void
{
$warehouse = Warehouse::query()->find($id);
$this->editingId = $id;
$this->name = $warehouse->name;
$this->modalOpen = true;
}
public function saveWarehouse(): void
{
if ($this->editingId) {
$warehouse = Warehouse::query()->findOrFail($this->editingId);
$warehouse->update(['name' => $this->name]);
}
}
Что здесь не так:
- Есть проверка
Auth::user()->can('edit.warehouses')— но это permission на действие, не на объект - Нет проверки: «а имеет ли этот пользователь доступ именно к этому складу?»
- Любой пользователь с permission
edit.warehousesможет отредактировать любой склад, подставив нужный ID
Permission ≠ Policy. В чём разница
can('edit.warehouses') — это проверка роли или разрешения. Она отвечает на вопрос: может ли этот пользователь вообще редактировать склады?
Policy — это проверка на уровне объекта. Она отвечает на вопрос: может ли этот пользователь редактировать именно этот склад?
Пример Policy:
class WarehousePolicy
{
public function update(User $user, Warehouse $warehouse): bool
{
// Здесь твоя бизнес-логика:
// - только owner может редактировать?
// - только admin?
// - только тот, кто создал запись?
return $user->hasRole('admin') || $user->can('edit.warehouses');
}
public function forceDelete(User $user, Warehouse $warehouse): bool
{
return $user->hasRole('admin');
}
}
Policy регистрируется в AuthServiceProvider:
protected $policies = [
\App\Models\Warehouse::class => \App\Policies\WarehousePolicy::class,
];
Как правильно защитить Livewire-компонент
Laravel предоставляет трейт AuthorizesRequests. Именно он добавляет метод $this->authorize() в Livewire-компоненты.
Подключаешь трейт:
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
class WarehousePage extends Component
{
use AuthorizesRequests;
// ...
}
И применяешь проверку по схеме: сначала получи запись, потом проверь доступ, потом мутируй:
// ✅ Безопасно
#[On('editWarehouse')]
public function editWarehouse($id): void
{
$warehouse = Warehouse::query()->find($id);
if (! $warehouse) {
$this->dispatch('notify', type: 'error', message: 'Склад не найден.');
return;
}
// Проверяем доступ именно к этому объекту
$this->authorize('update', $warehouse);
$this->editingId = $id;
$this->name = $warehouse->name;
$this->modalOpen = true;
}
public function saveWarehouse(): void
{
if ($this->editingId) {
$warehouse = Warehouse::query()->findOrFail($this->editingId);
$this->authorize('update', $warehouse); // снова — перед изменением
$warehouse->update(['name' => $this->name]);
} else {
$this->authorize('create', Warehouse::class);
Warehouse::query()->create(['name' => $this->name]);
}
}
#[On('deleteWarehouse')]
public function deleteWarehouse($id): void
{
$warehouse = Warehouse::withTrashed()->findOrFail($id);
$this->authorize('forceDelete', $warehouse);
$warehouse->forceDelete();
}
Три правила, которые легко запомнить:
find/findOrFail→ сразуauthorize()— никаких операций между ними- create →
authorize('create', Model::class)— до сохранения - Permission-проверка — это дополнительный слой, не замена policy
Что будет, если authorize() упадёт
$this->authorize() бросает AuthorizationException. Livewire перехватывает его и возвращает 403 клиенту. Компонент не ломается, данные не изменяются.
Важный нюанс: не глотай это исключение случайно:
// ❌ Так делать нельзя — теряешь 403
try {
$warehouse = Warehouse::findOrFail($id);
$this->authorize('update', $warehouse);
$warehouse->update([...]);
} catch (\Exception $e) {
// AuthorizationException тоже попадёт сюда и будет проглочена
$this->dispatch('notify', message: 'Ошибка', type: 'error');
}
// ✅ Правильно — перебрасываем авторизационное исключение
try {
$warehouse = Warehouse::findOrFail($id);
$this->authorize('update', $warehouse);
$warehouse->update([...]);
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
throw $e; // дать Livewire обработать 403
} catch (\Exception $e) {
$this->dispatch('notify', message: 'Ошибка: ' . $e->getMessage(), type: 'error');
}
Как протестировать авторизацию
Security-тесты для Livewire пишутся очень просто:
public function test_update_warehouse_forbidden_for_user_without_permission(): void
{
$warehouse = Warehouse::factory()->create(['name' => 'Original']);
Livewire::actingAs($this->viewOnlyUser)
->test(WarehousePage::class)
->set('editingId', $warehouse->id)
->set('name', 'Hacked Name')
->call('saveWarehouse')
->assertForbidden(); // ← именно это нам нужно
// Убеждаемся, что данные не изменились
$this->assertDatabaseHas('warehouses', [
'id' => $warehouse->id,
'name' => 'Original',
]);
}
Минимальный набор тестов для каждого компонента:
- попытка чужим пользователем →
assertForbidden() - успешное действие авторизованным → проверка в БД
- подмена ID чужой записи → данные не изменились
Частая ошибка
Добавить use AuthorizesRequests — это не достаточно. Трейт только предоставляет метод authorize(). Если его не вызвать — никакой защиты нет.
Другая ловушка: забыть трейт, но написать $this->authorize(). Тогда ты получишь BadMethodCallException в рантайме — и только если этот метод будет вызван. Тесты поймают, но продакшн — нет.
// Так не работает — трейт не подключен
class WarehousePage extends Component
{
// нет use AuthorizesRequests
public function deleteWarehouse($id): void
{
$warehouse = Warehouse::findOrFail($id);
$this->authorize('forceDelete', $warehouse); // BadMethodCallException в рантайме
$warehouse->forceDelete();
}
}
Итог
IDOR — это не экзотическая уязвимость. Это классическая ошибка, которую легко сделать в Livewire, если не думать о разнице между «может ли пользователь это делать» и «может ли он делать это с конкретным объектом».
Правило простое:
Любая мутация модели в Livewire =
authorize()перед действием
Чек-лист для каждого компонента:
- ✅ Подключён
use AuthorizesRequests - ✅ Для каждой мутации есть
$this->authorize('action', $model)или$this->authorize('create', Model::class) - ✅
AuthorizationExceptionне глотается в общемcatch - ✅ Есть security-тест с
assertForbidden()для недоступных сценариев - ✅ Policy зарегистрирована в
AuthServiceProvider
Livewire — отличный инструмент. Но безопасность он за тебя не пишет.