Перейти к основному содержанию
Блог

Веб

IDOR и ошибки авторизации: доступ к данным другого пользователя

Можно ли открыть чужой отчёт, поменяв число в URL? Что такое IDOR, как его найти и как закрыть с помощью policy в Laravel.

· 8 мин. чтения

Фото: Vyacheslav Argenberg, CC BY 4.0 · Wikimedia Commons

Представьте: пользователь открывает свой счёт по адресу /invoices/1042. Затем меняет в адресной строке 1042 на 1041 — и видит счёт другой организации. Это IDOR (Insecure Direct Object Reference) — прямое обращение к объекту без проверки владельца.

IDOR — самое распространённое проявление категории A01 «Broken Access Control», которая занимает первое место в OWASP Top 10 (2021). В CWE это CWE-639 (Authorization Bypass Through User-Controlled Key). Автоматический сканер почти никогда его не находит: он не знает, какая запись кому принадлежит.

Аутентификация и авторизация — разные вещи

Корень многих ошибок — в смешении этих двух понятий:

  • Аутентификация — «кто ты?» Вошёл ли пользователь в систему.
  • Авторизация — «тебе это разрешено?» Может ли именно этот пользователь видеть или менять именно эту запись.

Middleware auth отвечает только на первый вопрос. Без второй проверки любой вошедший пользователь может отправить запрос с любым ID.

Где встречается IDOR

  • Идентификаторы в URL: /orders/15, /users/7/edit, /reports/2026-09.pdf?id=88.
  • API-запросы: GET /api/documents/301, DELETE /api/comments/55.
  • Скрытые поля в формах: <input type="hidden" name="user_id" value="7">.
  • Скачивание файлов: /download?file=contract_15.pdf.
  • Параметр organization_id в мультитенантных системах.
  • Страница возврата после оплаты: /payment/result?order=124.

Последний случай особенно часто упускают. В статье о правильном процессе оплаты мы отдельно подчёркивали, что страница возврата должна открываться только владельцу заказа.

Пример ошибки в Laravel

public function show($id)
{
    $invoice = Invoice::findOrFail($id);
    return Inertia::render('Invoices/Show', ['invoice' => $invoice]);
}

Маршрут может находиться внутри middleware auth, но этот код вообще не проверяет, кому принадлежит запись.

Правильное решение: policy

В Laravel для хранения логики авторизации в одном месте используются policy:

php artisan make:policy InvoicePolicy --model=Invoice
class InvoicePolicy
{
    public function view(User $user, Invoice $invoice): bool
    {
        return $invoice->organization_id === $user->current_organization_id;
    }

    public function update(User $user, Invoice $invoice): bool
    {
        return $this->view($user, $invoice)
            && $user->hasRole('accountant')
            && $invoice->status === 'draft';
    }
}

В контроллере:

public function show(Invoice $invoice)
{
    $this->authorize('view', $invoice);
    return Inertia::render('Invoices/Show', ['invoice' => $invoice]);
}

Или на уровне маршрута:

Route::get('/invoices/{invoice}', [InvoiceController::class, 'show'])
    ->can('view', 'invoice');

Обратите внимание: в методе update помимо владения проверяются роль и статус записи. Авторизация — это не только вопрос «чьё это», но и вопрос «можно ли выполнить это действие в этом состоянии».

Ещё один слой: ограничение запроса по владельцу

В списках и поиске вместо проверки каждой записи через policy ограничьте сам запрос:

$invoices = $request->user()->currentOrganization
    ->invoices()
    ->latest()
    ->paginate(20);

Тогда записи другой организации вообще не извлекаются из базы. Для одной записи работает тот же приём: ->invoices()->findOrFail($id) вернёт 404 для чужого ID.

Правило: любой идентификатор от пользователя — это всего лишь запрос. Сервер каждый раз сам проверяет: «принадлежит ли запись этому пользователю и разрешено ли это действие?»

Типичные ошибки

  • Скрытие только в интерфейсе. Даже если кнопки или ссылки не видно, запрос можно отправить вручную.
  • Защищено только чтение. show проверяется, а update и destroy забыты.
  • Массовое обновление. $invoice->update($request->all()) может позволить пользователю поменять поле organization_id или user_id. Явно задайте $fillable и используйте провалидированные данные.
  • Расчёт на UUID. Неугадываемые ID помогают, но не заменяют авторизацию: ID может утечь из другого места (лог, ссылка, экспорт).
  • Связанные объекты. В /projects/5/tasks/90 проверяется проект, но не проверяется, что задача принадлежит этому проекту.

Как проверить

Самый эффективный способ — ручная проверка с двумя тестовыми аккаунтами:

  1. Создайте двух пользователей, по возможности в разных организациях.
  2. В первом аккаунте создайте записи и запишите их ID.
  3. Со второго аккаунта отправьте на эти ID запросы GET, PUT, DELETE.
  4. Каждый ответ должен быть 403 или 404, а не 200.
  5. Превратите проверки прав в автотесты, чтобы будущие изменения их не сломали:
it('не показывает счёт другой организации', function () {
    $invoice = Invoice::factory()->create();
    $stranger = User::factory()->create();

    $this->actingAs($stranger)
        ->get("/invoices/{$invoice->id}")
        ->assertForbidden();
});

Итог

Технически IDOR прост, но чтобы его найти, нужно пройти каждый маршрут и найти в нём проверку владельца. В большом проекте это занимает много времени. В нашем процессе Claude AI агент быстро просматривает все маршруты, контроллеры и policy и выделяет места без проверок, а специалист на практике проверяет их с двумя аккаунтами и исправляет.

Если хотите убедиться, что в вашем проекте нет пути к чужим данным, оставьте заявку на сканирование. Мы поможем закрыть пробелы в авторизации до официальной экспертизы.