IDOR виглядає нудно, і саме тому його так часто знаходять. Маршрут /resumes/{id} стоїть під auth, контролер робить Resume::findOrFail($id) і віддає сторінку. Автентифікація тут є: сервер знає, хто прийшов. Авторизації на рівні обʼєкта немає: ніхто не спитав, чи це резюме належить тому, хто прийшов. Достатньо змінити число в адресі, і зареєстрований учора користувач гортає чужі резюме з телефонами. В OWASP Top 10:2025 це частина A01 Broken Access Control, а в OWASP API Security Top 10:2023 та сама проблема стоїть першою як API1 Broken Object Level Authorization.
Є два способи перевіряти доступ, і на співбесіді добре показати обидва. Перший: знайти обʼєкт, а потім спитати policy (Gate::authorize('view', $resume)). Він гнучкий і потрібен, коли правило складніше за «власник»: рекрутер бачить резюме лише тих, хто відгукнувся на його вакансію. Другий: шукати обʼєкт одразу в межах власника, $request->user()->resumes()->findOrFail($id). Його перевага в тому, що забути перевірку неможливо, бо вона і є запитом: чужий id просто не знаходиться, і клієнт отримує 404, який нічого не розголошує. У мультитенантних застосунках цю ідею доводять до глобального скоупу моделі, щоб будь-який запит без тенанта був помилкою.
Вкладені ресурси додають свою пастку. scopeBindings() на маршруті /companies/{company}/vacancies/{vacancy} шукає вакансію через $company->vacancies(), тож вакансія іншої компанії дає 404. Але сам company у URL теж прийшов від користувача, і якщо право на компанію не перевірене (can:manage,company), людина підставить у URL чужу компанію разом з її вакансією і змінить її. Те саме з полями у тілі запиту: company_id чи user_id при створенні беруть з $request->user(), а не з форми, інакше запис зʼявляється від чужого імені.
Найчастіше IDOR знаходять не в show, а поруч. Списковий index без фільтра за власником, масове видалення з whereIn('id', $ids) без скоупу, експорт у CSV, посилання на файл за id, ендпоїнт для мобільного застосунку, який написали пізніше і без policy, Livewire-компонент із public int $resumeId, що приходить із браузера. Перехід на UUID робить перебір складнішим, але не додає перевірки доступу: ідентифікатори все одно течуть через листи, логи і заголовок Referer. Надійний спосіб тримати це під контролем: тест із двома користувачами на кожен ендпоїнт, що приймає ідентифікатор, і assertNotFound() для чужого обʼєкта.
// Було: auth пускає будь-кого з сесією, а id береться прямо з URL.
public function show(int $id): View
{
return view('resumes.show', ['resume' => Resume::findOrFail($id)]);
}
// Стало: шукаємо лише серед своїх. Чужий id дає 404, а не чуже резюме.
public function show(Request $request, int $id): View
{
$resume = $request->user()->resumes()->findOrFail($id);
return view('resumes.show', ['resume' => $resume]);
}
// Коли правило складніше за «власник» (рекрутер бачить резюме відгуку): policy.
public function showForEmployer(Resume $resume): View
{
Gate::authorize('view', $resume);
return view('resumes.show', ['resume' => $resume]);
}
// Вкладений маршрут: scopeBindings перевіряє звʼязок вакансії з компанією,
// can:manage перевіряє право користувача на саму компанію.
Route::put('/companies/{company}/vacancies/{vacancy}', UpdateVacancy::class)
->scopeBindings()
->middleware(['auth', 'can:manage,company']);
// Масова дія: whereIn лише в межах своїх записів.
$request->user()->resumes()->whereIn('id', $request->input('ids', []))->delete();
Скажіть, що найкраща перевірка доступу та, яку неможливо забути: запит, який шукає лише серед даних користувача, повертає 404 на чужий id навіть тоді, коли policy ніхто не написав. Policy додайте як другий рівень для правил, складніших за «власник».