Коли id з адреси відкриває чужий запис
IDOR (Insecure Direct Object Reference) трапляється, коли застосунок бере ідентифікатор із адреси, форми чи JSON і віддає або змінює запис, не перевіривши, чи він належить тому, хто питає. Людина бачить свій відгук за адресою /applications/41, пише 42 і читає чужий. Логін справжній, CSRF-токен правильний, жодного дивного символу в запиті. У OWASP Top 10:2025 це частина A01 Broken Access Control, першої категорії списку.
Тут легко сплутати дві перевірки. відповідає, хто прийшов: middleware auth бачить сесію й пускає далі. відповідає, чи можна цій людині саме цей запис і саме цю дію. Laravel робить першу за вас, а другу зробити не може, бо не знає, кому що належить: це знає лише ваша модель даних.
Найпростіший захист шукає запис одразу серед своїх: $request->user()->applications()->findOrFail($id). Запит отримує умову where user_id = ?, і чужий рядок просто не знаходиться, а findOrFail відповідає 404. Коли правил більше (колега з тієї ж компанії, адміністратор, лише чернетку можна редагувати), їх збирає , а контролер викликає Gate::authorize('update', $application) або маршрут має ->can('update', 'application'). У скелеті Laravel 11 і новіших базовий Controller порожній, тож $this->authorize() працює лише з трейтом AuthorizesRequests.
Route model binding не перевіряє доступ: show(Application $application) знайде будь-який відгук за id і дасть 404 лише тоді, коли такого рядка немає взагалі. на маршруті /users/{user}/posts/{post} перевіряє, що пост належить користувачу з адреси, а не тому, хто залогінений. Атакувальник підставить у адресу обидва чужі id.
UUID чи ULID замість числа ускладнюють , але доступу не перевіряють, а самі id потрапляють у листи, логи, заголовок Referer і скриншоти. Для чужого приватного запису відповідайте 404, як на відсутній: 403 підтверджує, що запис існує. 403 доречний, коли людина бачить обʼєкт, але не може виконати дію, як-от прочитати вакансію й не мати права її закрити. У Policy для 404 є Response::denyAsNotFound().
Три кроки від свого відгуку до чужого
- Атакувальник · id 7 створює GET /applications/42.
- GET /applications/42 використовує Контролер (сесія справжня).
- Контролер використовує applications (findOrFail(42)).
- Дані йдуть від applications до Відгук Тараса.
Шукати лише серед своїх
-
1
рядки 10–11
Пошук отримує і id з адреси, і власника. У Laravel те саме дає звʼязок: applications() додає where user_id = ? до запиту.
-
2
рядки 15–17
Чужий запис і запис, якого немає, дають ту саму відповідь. Перебором не дізнатися, які id існують.
-
3
рядки 22–22
Власник береться із сесії. user_id із форми чи адреси атакувальник підмінить так само легко, як id запису.
-
4
рядки 24–31
41 належить користувачу 7 і відкривається. 42 існує, але чужий, тож 404, як і для 999.
- Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
Laravel і Symfony не знають, кому що належить
public function show(Application $application)
Чому це дірка: Binding шукає запис за id і дає 404 лише тоді, коли рядка немає. Власника він не перевіряє. Додайте Gate::authorize('view', $application) або ->can('view', 'application') на маршрут.
Route::get('/users/{user}/posts/{post}', ...)->scopeBindings();
Чому це дірка: Перевіряє, що пост належить {user} з тієї ж адреси. Атакувальник підставить чужий user разом із чужим post, і адреса буде цілком узгодженою.
class ApplicationPolicy { public function update(...) }
Чому це дірка: Policy сама не спрацьовує. Laravel знаходить її за назвою моделі, але викликає лише через Gate::authorize, $user->can, middleware can чи @can. Кнопка, схована через @can, не захищає маршрут.
Application::whereIn('id', $request->input('ids'))->delete();
Чому це дірка: Список id приходить із форми, і в ньому можуть бути чужі. Фільтруйте через звʼязок: $request->user()->applications()->whereIn('id', $ids).
$job->applications()->create($request->only('user_id', 'letter'));
Чому це дірка: Власник нового запису береться з $request->user(), а не з поля запиту. Інакше людина створює запис від імені іншого.
public int $jobId;
Чому це дірка: Публічна властивість повертається з браузера з кожним запитом, і її можна змінити в devtools. #[Locked] забороняє зміну, а дія все одно перевіряє власника.
Route::get('/invoices/{invoice:uuid}', ...);
Чому це дірка: UUID важко вгадати, але він живе в листах, логах, Referer і скриншотах. Перевірка власника потрібна так само, як для числа.
public function edit(Post $post): Response
Чому це дірка: Symfony теж завантажує сутність за id з маршруту й не перевіряє права. Додайте #[IsGranted('POST_EDIT', 'post')] або $this->denyAccessUnlessGranted('POST_EDIT', $post) з Voter.
abort_unless($application->user_id === $user->id, 403);
Чому це дірка: Відповідь «заборонено» каже, що запис з таким id існує. Для чужих приватних записів віддавайте 404, як для відсутніх, у Policy через Response::denyAsNotFound().
Де тут IDOR
Натисніть на рядки, у яких бачите вразливість, і перевірте. Тут чотири проблеми.
Вставте у свій шаблон пул-реквесту
- Кожен запис за id з адреси, форми чи JSON шукається серед записів власника: через звʼязок $request->user()->... або явну умову where.
- Дії з умовами (команда, роль, статус) проходять через Policy: Gate::authorize або ->can() на маршруті. @can у шаблоні лише ховає кнопку.
- Route model binding, scopeBindings і UUID не вважаються перевіркою доступу.
- Власник нового запису береться з $request->user(), а не з поля запиту.
- Масові дії фільтрують список id за власником.
- Публічні id у Livewire позначено #[Locked], і дія все одно перевіряє власника.
- Для чужого приватного запису відповідь 404, без різниці між «немає» і «не ваше».
- Feature-тест для кожного маршруту з {id}: другий користувач отримує 404 на чужий запис.
Як цей сайт перевіряє власника відгуку
Кожен маршрут відгуків у кабінеті розробника: /profile/applications/{id}, резюме відгуку, calendar.ics і відкликання, викликає WithdrawApplication::own з id з адреси й id користувача із сесії. Чужий відгук і відгук, якого немає, дають ту саму помилку, а ProfileController для сторінки, резюме й calendar.ics відповідає на неї 404. Перевірка живе в шарі застосунку, тож нею користуються і контролер, і сама дія відкликання.
app/Http/Controllers/ProfileController.phpapplicationResume віддає файл із приватного диска лише після own(), інакше 404. resumeOf для резюме з картки пускає власника, перевіреного роботодавця для публічної картки або компанію, якій розробник відгукнувся, решті 403.src/Hiring/Application/Employer/MoveApplication.phpКабінет компанії: own() порівнює companyId відгуку з компанією користувача. Через нього йдуть картка кандидата, нотатки, етапи й резюме.app/Livewire/Employer/PostJobForm.phpjobId вакансії позначено #[Locked], і все одно mount та збереження перевіряють SavePosting::own: вакансія має належати компанії користувача.- Автентифікація
- Перевірка, хто ви: пароль, вхід через провайдера, сесія чи токен. У Laravel це guard і middleware auth.
- Авторизація
- Перевірка, що вам можна: цей запис, цю дію, у цьому стані. У Laravel це Gate, Policy, middleware can і умови в запитах.
- Policy
- Клас із методами view, update, delete на одну модель. Кожен отримує користувача й запис і повертає true, false або Response. Laravel знаходить Policy за назвою моделі, але викликає лише тоді, коли ви просите.
- scopeBindings
- Режим route model binding для вкладених параметрів: дочірній запис шукається через звʼязок батьківського з тієї ж адреси. Захищає від невідповідності частин адреси, а не від чужого користувача.
- Перебір id
- Атака, коли скрипт проходить id від 1 до N і збирає все, що сервер віддасть. Послідовні числа роблять її тривіальною, а різні відповіді на «немає» і «не ваше» показують, які записи існують.
Питання зі співбесід
Автентифікація встановлює, хто прийшов: пароль, сесія, токен. Авторизація вирішує, що цій людині можна з конкретним записом. IDOR це помилка авторизації: людина залогінена чесно, але отримує чужий обʼєкт, бо код не перевірив власника. Middleware auth від цього не рятує.
Binding робить Application::findOrFail за id з адреси й не знає про власника. Два способи: шукати через звʼязок ($request->user()->applications()->findOrFail($id)), тоді чужий запис просто не знайдеться, або лишити binding і викликати Gate::authorize('view', $application) з Policy, чи повісити ->can('view', 'application') на маршрут. Policy зручніша, коли правил кілька: команда, роль, статус запису.
Для приватних даних 404, як на відсутній запис. 403 підтверджує, що id існує, і полегшує перебір. 403 доречний, коли обʼєкт людині видно, але дія заборонена, наприклад читати вакансію можна, а закрити ні. У Laravel Policy може повернути Response::denyAsNotFound().
Зробити безпечний шлях найкоротшим: репозиторії й сервіси приймають власника (own($id, $userId)), а не голий id, запити йдуть через звʼязки, для мультитенантності global scope за tenant_id. Правила зі станом у Policy. Тест на кожен маршрут з {id}: другий користувач отримує 404, і його легко генерувати з route:list. UUID лише як додатковий барʼєр. У ревʼю окремо дивитися на масові дії, Livewire-властивості й поля власника у формах. Сплеск 404 з одного акаунта варто логувати й обмежувати throttle.