<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
БЕЗПЕКА · КРИТИЧНІСТЬ: КРИТИЧНА OWASP 2025 A01 · Broken Access Control

IDOR: доступ до чужих данихInsecure Direct Object Reference

Людина змінює число в адресі з 41 на 42 і відкриває чужий відгук, рахунок чи резюме. Сесія справжня, логін правильний, а сервер просто не спитав, чий це запис.

11 хв читання Junior → Senior
01 · ЩО ЦЕ

Коли id з адреси відкриває чужий запис

IDOR (Insecure Direct Object Reference) трапляється, коли застосунок бере ідентифікатор із адреси, форми чи JSON і віддає або змінює запис, не перевіривши, чи він належить тому, хто питає. Людина бачить свій відгук за адресою /applications/41, пише 42 і читає чужий. Логін справжній, CSRF-токен правильний, жодного дивного символу в запиті. У OWASP Top 10:2025 це частина A01 Broken Access Control, першої категорії списку.

Тут легко сплутати дві перевірки. відповідає, хто прийшов: middleware auth бачить сесію й пускає далі. відповідає, чи можна цій людині саме цей запис і саме цю дію. Laravel робить першу за вас, а другу зробити не може, бо не знає, кому що належить: це знає лише ваша модель даних.

СЛОВНИКАвтентифікація. Перевірка, хто ви: пароль, вхід через провайдера, сесія чи токен. У Laravel це guard і middleware auth.
СЛОВНИКАвторизація. Перевірка, що вам можна: цей запис, цю дію, у цьому стані. У Laravel це Gate, Policy, middleware can і умови в запитах.

Найпростіший захист шукає запис одразу серед своїх: $request->user()->applications()->findOrFail($id). Запит отримує умову where user_id = ?, і чужий рядок просто не знаходиться, а findOrFail відповідає 404. Коли правил більше (колега з тієї ж компанії, адміністратор, лише чернетку можна редагувати), їх збирає , а контролер викликає Gate::authorize('update', $application) або маршрут має ->can('update', 'application'). У скелеті Laravel 11 і новіших базовий Controller порожній, тож $this->authorize() працює лише з трейтом AuthorizesRequests.

СЛОВНИКPolicy. Клас із методами view, update, delete на одну модель. Кожен отримує користувача й запис і повертає true, false або Response. Laravel знаходить Policy за назвою моделі, але викликає лише тоді, коли ви просите.

Route model binding не перевіряє доступ: show(Application $application) знайде будь-який відгук за id і дасть 404 лише тоді, коли такого рядка немає взагалі. на маршруті /users/{user}/posts/{post} перевіряє, що пост належить користувачу з адреси, а не тому, хто залогінений. Атакувальник підставить у адресу обидва чужі id.

СЛОВНИКscopeBindings. Режим route model binding для вкладених параметрів: дочірній запис шукається через звʼязок батьківського з тієї ж адреси. Захищає від невідповідності частин адреси, а не від чужого користувача.

UUID чи ULID замість числа ускладнюють , але доступу не перевіряють, а самі id потрапляють у листи, логи, заголовок Referer і скриншоти. Для чужого приватного запису відповідайте 404, як на відсутній: 403 підтверджує, що запис існує. 403 доречний, коли людина бачить обʼєкт, але не може виконати дію, як-от прочитати вакансію й не мати права її закрити. У Policy для 404 є Response::denyAsNotFound().

СЛОВНИКПеребір id. Атака, коли скрипт проходить id від 1 до N і збирає все, що сервер віддасть. Послідовні числа роблять її тривіальною, а різні відповіді на «немає» і «не ваше» показують, які записи існують.
02 · ЯК ДУМАЄ АТАКУВАЛЬНИК

Три кроки від свого відгуку до чужого

ДІАГРАМА · УСЯ СХЕМА
Кроки: 3
41 → 42сесія справжняfindOrFail(42)200 OKАтакувальник · id 7:GET /applications/42Контролерapplications:Відгук Тараса
Атакувальник · id 7:GET /applications/42Контролерapplications:Відгук Тараса
Уся схема. Натисніть → або виберіть крок нижче, щоб пройти по ній частинами.
Клієнт Ключовий учасник Інфраструктура
  • Атакувальник · id 7 створює GET /applications/42.
  • GET /applications/42 використовує Контролер (сесія справжня).
  • Контролер використовує applications (findOrFail(42)).
  • Дані йдуть від applications до Відгук Тараса.
03 · ВРАЗЛИВИЙ І ЗАХИЩЕНИЙ КОД

Шукати лише серед своїх

PHP 8.5
1<?php
2
3$applications = [
4 41 => ['user_id' => 7, 'job' => 'Laravel Developer', 'salary' => 3000],
5 42 => ['user_id' => 9, 'job' => 'Symfony Developer', 'salary' => 4200],
6];
7
8$currentUserId = 7;
9$id = 42; // з адреси /applications/42
10
11// Було: запис за id з адреси, без питання «чий він».
12$application = $applications[$id] ?? null;
13
14if ($application === null) {
15 echo "404\n";
16} else {
17 echo "200: {$application['job']}, {$application['salary']}\n";
18}
1<?php
2
3final class NotFound extends RuntimeException {}
4
5$applications = [
6 41 => ['user_id' => 7, 'job' => 'Laravel Developer', 'salary' => 3000],
7 42 => ['user_id' => 9, 'job' => 'Symfony Developer', 'salary' => 4200],
8];
9
101// Як $request->user()->applications()->findOrFail($id).
11function findOwned(array $rows, int $id, int $userId): array
12{
13 $row = $rows[$id] ?? null;
14
152 if ($row === null || $row['user_id'] !== $userId) {
16 throw new NotFound('Відгук не знайдено');
17 }
18
19 return $row;
20}
21
223$currentUserId = 7; // із сесії, ніколи з запиту
23
244foreach ([41, 42, 999] as $id) {
25 try {
26 $row = findOwned($applications, $id, $currentUserId);
27 echo "GET /applications/{$id}: 200, {$row['job']}\n";
28 } catch (NotFound $e) {
29 echo "GET /applications/{$id}: 404, {$e->getMessage()}\n";
30 }
31}
Перший запуск завантажить рушій PHP 8.5, приблизно 3,8 МБ Відкрити в пісочниці ↗
ВИВІД
Завантажуємо рушій PHP 8.5
Лише перший раз. Далі код запускається миттєво, рушій лишається в кеші браузера.
$ php main.php ▍
  1. 1 рядки 10–11

    Пошук отримує і id з адреси, і власника. У Laravel те саме дає звʼязок: applications() додає where user_id = ? до запиту.

  2. 2 рядки 15–17

    Чужий запис і запис, якого немає, дають ту саму відповідь. Перебором не дізнатися, які id існують.

  3. 3 рядки 22–22

    Власник береться із сесії. user_id із форми чи адреси атакувальник підмінить так само легко, як id запису.

  4. 4 рядки 24–31

    41 належить користувачу 7 і відкривається. 42 існує, але чужий, тож 404, як і для 999.

  5. Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
04 · ПАСТКИ ФРЕЙМВОРКУ

Laravel і Symfony не знають, кому що належить

LARAVEL · ROUTE MODEL BINDING
public function show(Application $application)

Чому це дірка: Binding шукає запис за id і дає 404 лише тоді, коли рядка немає. Власника він не перевіряє. Додайте Gate::authorize('view', $application) або ->can('view', 'application') на маршрут.

LARAVEL · SCOPEBINDINGS
Route::get('/users/{user}/posts/{post}', ...)->scopeBindings();

Чому це дірка: Перевіряє, що пост належить {user} з тієї ж адреси. Атакувальник підставить чужий user разом із чужим post, і адреса буде цілком узгодженою.

LARAVEL · POLICY
class ApplicationPolicy { public function update(...) }

Чому це дірка: Policy сама не спрацьовує. Laravel знаходить її за назвою моделі, але викликає лише через Gate::authorize, $user->can, middleware can чи @can. Кнопка, схована через @can, не захищає маршрут.

LARAVEL · МАСОВІ ДІЇ
Application::whereIn('id', $request->input('ids'))->delete();

Чому це дірка: Список id приходить із форми, і в ньому можуть бути чужі. Фільтруйте через звʼязок: $request->user()->applications()->whereIn('id', $ids).

LARAVEL · ВЛАСНИК З ФОРМИ
$job->applications()->create($request->only('user_id', 'letter'));

Чому це дірка: Власник нового запису береться з $request->user(), а не з поля запиту. Інакше людина створює запис від імені іншого.

LIVEWIRE · ПУБЛІЧНИЙ ID
public int $jobId;

Чому це дірка: Публічна властивість повертається з браузера з кожним запитом, і її можна змінити в devtools. #[Locked] забороняє зміну, а дія все одно перевіряє власника.

UUID У МАРШРУТІ
Route::get('/invoices/{invoice:uuid}', ...);

Чому це дірка: UUID важко вгадати, але він живе в листах, логах, Referer і скриншотах. Перевірка власника потрібна так само, як для числа.

SYMFONY · ENTITYVALUERESOLVER
public function edit(Post $post): Response

Чому це дірка: Symfony теж завантажує сутність за id з маршруту й не перевіряє права. Додайте #[IsGranted('POST_EDIT', 'post')] або $this->denyAccessUnlessGranted('POST_EDIT', $post) з Voter.

403 ЗАМІСТЬ 404
abort_unless($application->user_id === $user->id, 403);

Чому це дірка: Відповідь «заборонено» каже, що запис з таким id існує. Для чужих приватних записів віддавайте 404, як для відсутніх, у Policy через Response::denyAsNotFound().

05 · ЗНАЙДИ ПРОБЛЕМУ

Де тут IDOR

Натисніть на рядки, у яких бачите вразливість, і перевірте. Тут чотири проблеми.

app/Http/Controllers/ApplicationController.php натисніть на підозрілі рядки
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: сам цей рядок не вразливий.
Пропущено. Так, тут IDOR. findOrFail шукає серед усіх відгуків. Потрібно $request->user()->applications()->findOrFail($id), як у методі resume нижче.
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: запит обмежено власником, чужий id дасть 404.
Тут усе гаразд: файл береться з приватного диска лише після перевірки власника в рядку вище.
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: сам цей рядок не вразливий.
Сам binding не помилка: він лише знаходить відгук за id. Власника тут ніхто не перевіряє, і цієї перевірки бракує рядком нижче.
Тут усе гаразд: сам цей рядок не вразливий.
Пропущено. Теж вразливо. Binding знайшов відгук за id, і ніхто не перевірив, чий він. Перед update потрібен Gate::authorize('update', $application).
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: сам цей рядок не вразливий.
Пропущено. І тут. Масове видалення за id із форми: у списку можуть бути чужі. Фільтруйте через $request->user()->applications().
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: сам цей рядок не вразливий.
Пропущено. IDOR на запис. user_id приходить із запиту, тож можна відгукнутися від імені іншої людини. Власник береться з $request->user()->id.
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: сам цей рядок не вразливий.
06 · ЧЕКЛІСТ ДЛЯ PR

Вставте у свій шаблон пул-реквесту

## Захист від 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 на чужий запис.
07 · ЯК У PHP UKRAINE

Як цей сайт перевіряє власника відгуку

Кожен маршрут відгуків у кабінеті розробника: /profile/applications/{id}, резюме відгуку, calendar.ics і відкликання, викликає WithdrawApplication::own з id з адреси й id користувача із сесії. Чужий відгук і відгук, якого немає, дають ту саму помилку, а ProfileController для сторінки, резюме й calendar.ics відповідає на неї 404. Перевірка живе в шарі застосунку, тож нею користуються і контролер, і сама дія відкликання.

php WithdrawApplication.php фрагмент
1public function own(int $applicationId, int $userId): Application← userId приходить із сесії
2{
3 $application = $this->applications->findById($applicationId);
4
5 if ($application === null || $application->userId !== $userId) {← немає чи чужий, однаково
6 throw new InvalidArgumentException('Відгук не знайдено.');
7 }
8
9 return $application;
10}
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: вакансія має належати компанії користувача.
08 · СЛОВНИК
Автентифікація
Перевірка, хто ви: пароль, вхід через провайдера, сесія чи токен. У 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 і збирає все, що сервер віддасть. Послідовні числа роблять її тривіальною, а різні відповіді на «немає» і «не ваше» показують, які записи існують.
09 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з безпеки →

Автентифікація встановлює, хто прийшов: пароль, сесія, токен. Авторизація вирішує, що цій людині можна з конкретним записом. 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.