Загрузка...

IDOR в Livewire: как потерять данные клиента одним запросом

laravel cover

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();
}

Три правила, которые легко запомнить:

  1. find/findOrFail → сразу authorize() — никаких операций между ними
  2. create → authorize('create', Model::class) — до сохранения
  3. 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 — отличный инструмент. Но безопасность он за тебя не пишет.

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

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