<? phpukraine СПІВБЕСІДИ
⌕Пошук по платформі
БЕЗПЕКА · MIDDLE ЧАСТО ПИТАЮТЬ

Що таке IDOR і як не віддати користувачу чужі дані?

IDOR (insecure direct object reference) виникає, коли застосунок знаходить обʼєкт за ідентифікатором із запиту і не перевіряє, чи належить він поточному користувачу. `auth` відповідає лише на питання «хто ви», тому для кожного обʼєкта потрібна окрема перевірка: шукати через звʼязок власника (`$user->resumes()->findOrFail($id)`), через policy або через `scopeBindings()` для вкладених маршрутів. UUID ускладнює перебір, але доступ не перевіряє.

Що не так з цим контролером: `return Resume::findOrFail($id);` за маршрутом під `auth`?
Я змінив id в адресі з 1041 на 1040 і побачив чуже резюме. Як таке пропустили і як виправити системно?
Ми перейшли з автоінкременту на UUID. Тепер IDOR не страшний?
Як перевірити доступ на маршруті `/companies/{company}/vacancies/{vacancy}`?
IDOR BOLA авторизація Policy scopeBindings мультитенантність

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();
Що автентифікація і авторизація на рівні обʼєкта це різні перевірки, і `auth` middleware другу не робить.
Що надійніше не перевіряти обʼєкт після вибірки, а одразу шукати його в межах власника чи тенанта: тоді забута перевірка дає 404, а не витік.
Що `scopeBindings()` перевіряє лише звʼязок дочірнього обʼєкта з батьківським, а право користувача на батьківський треба перевірити окремо.
Що IDOR трапляється далеко за межами `show`: списки, масові дії з `whereIn('id', ...)`, експорт, завантаження файлів, Livewire-властивості і API для мобільного застосунку.
Що таку вразливість ловлять тестом із двома користувачами, а не ревʼю окремої policy.
Вважати, що `auth` на маршруті закриває доступ: він пускає будь-якого залогіненого, зокрема того, хто щойно зареєструвався.
Покладатися на UUID чи хешовані id як на захист: ідентифікатори течуть через посилання, логи, листи й заголовок `Referer`, а перевірки доступу як не було, так і немає.
Перевіряти власника лише в `update` і `destroy`, забувши про `show`, експорт у CSV і ендпоїнт для мобільного застосунку з тією ж моделлю.
Брати `company_id` чи `user_id` з тіла запиту при створенні запису замість `$request->user()`: користувач створює записи від імені чужої компанії.
Зберігати id обʼєкта в публічній властивості Livewire-компонента і довіряти йому в дії: значення приходить з браузера і змінюється так само легко, як параметр URL.
ПОРАДА

Скажіть, що найкраща перевірка доступу та, яку неможливо забути: запит, який шукає лише серед даних користувача, повертає 404 на чужий id навіть тоді, коли policy ніхто не написав. Policy додайте як другий рівень для правил, складніших за «власник».

оновлено 8 жовтня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

`scopeBindings()` шукає дочірню модель через звʼязок батьківської, тому чужа вакансія для цієї компанії дає 404. Хто такий поточний користувач, він не знає, тому поруч потрібна `can` або перевірка в контролері. `auth` встановлює особу, а не право на обʼєкт. UUID течуть через посилання й логи і доступ не перевіряють. Policy відповідає на питання про конкретний обʼєкт, а вибірку для списку треба звужувати самим запитом.