Питання на співбесіді: Контроль доступу
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
15 питань
Автентифікація відповідає на питання «хто ви», авторизація - «що вам дозволено». Більшість реальних витоків даних - не злам паролів, а відсутня чи неповна перевірка доступу: OWASP Top 10 ставить «Broken Access Control» на перше місце.
Заборонено за замовчуванням (deny by default): доступ дозволяється, лише якщо є явне правило, що його дозволяє. Будь-яка непередбачена ситуація - відмова.
// погано: дозволено всім, крім явно заборонених
if ($user->is_banned) {
abort(403);
}
// добре: заборонено всім, крім явно дозволених
if (! $user->can('update', $post)) {
abort(403);
}
Чому це важливо: при «дозволено за замовчуванням» кожна забута перевірка - вразливість. При «заборонено за замовчуванням» забута перевірка - лише непрацююча функція, яку помітять і виправлять.
Перевірки - лише на сервері. Приховати кнопку «Видалити» в інтерфейсі - це UX, а не захист:
- запит можна відправити напряму (
curl, консоль браузера, змінений клієнт); - мобільний застосунок можна декомпілювати й побачити всі ендпойнти;
- у Livewire будь-який публічний метод компонента можна викликати з браузера.
Сервер має перевіряти права на кожну дію, незалежно від того, що показує інтерфейс.
Практичні принципи:
- перевірка на кожен запит, а не лише при вході на сторінку: права могли змінитися, а запит - бути підробленим;
- централізовано: у Laravel - політики й гейти, а не розкидані
if ($user->role === 'admin')по контролерах; - найменші права: користувач і сервіс отримують лише ті можливості, що потрібні для роботи;
- перевіряти конкретний об'єкт, а не лише тип дії: «може редагувати пости» ≠ «може редагувати цей пост»;
- відмова - без зайвих деталей:
403чи404без пояснення, які саме права відсутні.
Тестування: для кожної дії - тест, що користувач без прав отримує відмову. Позитивні сценарії перевіряються природно під час розробки, а відсутність заборони помічають лише тоді, коли її шукають.
Вертикальне підвищення привілеїв - звичайний користувач отримує можливості вищої ролі: адміністратора, модератора, менеджера.
Приклади:
- адмінка доступна за адресою
/admin, і перевіряється лише вхід, а не роль; - ендпойнт
POST /users/7/roleне перевіряє, хто змінює роль; - поле
is_adminможна передати у формі профілю (масове призначення); - роль зберігається на клієнті (у cookie без підпису, у
localStorage) - і її можна змінити.
Горизонтальне підвищення привілеїв - користувач отримує доступ до даних чи дій іншого користувача того самого рівня.
Приклади:
/invoices/1041- змінив число в адресі й бачиш чужий рахунок;PATCH /addresses/88змінює адресу іншого клієнта;- завантаження файлу за прямим посиланням без перевірки власника.
Горизонтальні вразливості трапляються частіше: перевірку ролі зазвичай пам'ятають (адмінка - очевидна мішень), а перевірку власності конкретного запису забувають у кожному новому ендпойнті.
Як захищатися:
Від вертикального:
- маршрути адмінки - в окремій групі з перевіркою ролі на рівні групи (middleware, політики);
- зміна ролей і прав - окремі ендпойнти з суворою авторизацією й журналом аудиту;
- роль - на сервері, а не в даних від клієнта.
Від горизонтального:
- пошук через власника:
$request->user()->invoices()->findOrFail($id)- чужий запис просто не знайдеться; - політики з перевіркою
$invoice->user_id === $user->id(чи належності до команди/організації); - обмеження вкладених маршрутів (
scopeBindings) - дочірній запис має належати батьківському.
Комбінований випадок: менеджер однієї компанії (рівень «менеджер» - вертикально все гаразд) бачить дані іншої компанії (горизонтально - ні). У багатоорендних застосунках перевірка належності до орендаря (tenant) потрібна для кожної ролі, включно з адміністраторами клієнтів.
Тест-матриця: для кожного ендпойнта - запит від анонімного користувача, від звичайного користувача-власника, від іншого користувача того самого рівня й від нижчої ролі. Очікувані відповіді - явно в тестах.
Гейти (gates) - замикання для дій, не прив'язаних до конкретної моделі:
// AppServiceProvider::boot()
Gate::define('view-reports', fn (User $user) => $user->hasRole('analyst'));
Gate::define('access-admin', fn (User $user) => $user->is_admin);
Політики (policies) - клас з правилами для однієї моделі:
php artisan make:policy PostPolicy --model=Post
class PostPolicy
{
public function update(User $user, Post $post): bool
{
return $user->id === $post->author_id;
}
public function delete(User $user, Post $post): bool
{
return $user->id === $post->author_id || $user->is_moderator;
}
}
Laravel знаходить політику за назвою (App\Models\Post → App\Policies\PostPolicy) або за атрибутом на моделі.
Як перевіряти:
// у контролері
Gate::authorize('update', $post); // 403, якщо заборонено
if ($request->user()->cannot('delete', $post)) { abort(403); }
// у Blade - лише для відображення
@can('update', $post) <a href="...">Редагувати</a> @endcan
// у Form Request
public function authorize(): bool { return $this->user()->can('update', $this->route('post')); }
На маршрутах - middleware can:
Route::put('/posts/{post}', [PostController::class, 'update'])->can('update', 'post');
Route::get('/admin/reports', ReportController::class)->middleware('can:view-reports');
Route::post('/posts', [PostController::class, 'store'])->can('create', Post::class);
'post' - назва параметра маршруту: після прив'язки моделі в політику потрапить конкретний пост. Для дій без екземпляра (create) передається клас.
Що обрати:
- політика - для всього, що стосується моделі (CRUD і власні дії). Логіка доступу до сутності - в одному місці;
- гейт - для загальних можливостей (доступ до адмінки, звітів, функцій тарифу).
Пастки:
@canу шаблоні лише ховає кнопку - перевірка на сервері (контролер, маршрут) обов'язкова;- методи політики повинні повертати
bool(абоResponse): забутийreturnдаєnull- і доступ заборонено, що безпечно, але збиває з пантелику; - гості: для неавтентифікованого користувача гейти й політики за замовчуванням повертають
false, якщо параметр користувача не оголошено як?User.
Докладніше в документації: Laravel: авторизація через middleware
За замовчуванням Laravel автоматично відмовляє неавтентифікованим користувачам у будь-якому гейті чи методі політики: метод навіть не викликається, а результат - false. Це безпечне значення за замовчуванням.
Щоб гість міг пройти перевірку, параметр користувача оголошують необов'язковим:
class PostPolicy
{
public function view(?User $user, Post $post): bool
{
if ($post->is_published) {
return true; // опубліковане бачать усі, включно з гостями
}
return $user !== null && $user->id === $post->author_id; // чернетку - лише автор
}
}
Gate::define('view-pricing', fn (?User $user) => true);
Типові сценарії:
- публічний перегляд опублікованих матеріалів і приватний - чернеток;
- каталог для всіх, а ціни для партнерів - лише після входу;
- форма зворотного зв'язку для гостей з обмеженням частоти.
Пастки:
1. ?User і забута перевірка на null:
public function view(?User $user, Post $post): bool
{
return $user->id === $post->author_id || $post->is_published; // помилка для гостя
}
Для гостя $user->id - звернення до властивості null: у PHP 8 це помилка (Attempt to read property "id" on null), а не false. Завжди перевіряти null першим.
2. Гостьовий доступ, відкритий занадто широко. Додали ?User у view, щоб показувати опубліковані пости, - і забули, що той самий метод використовується для перегляду чернеток у прев'ю. Кожен метод з ?User варто переглядати окремо: які гілки повертають true для гостя.
3. Неявна довіра до параметрів: «опубліковане» має визначатися полем у базі (is_published), а не параметром запиту (?preview=1).
4. Авторизація ≠ автентифікація маршруту. Middleware auth на маршруті відсіює гостей ще до політики. Якщо маршрут має бути доступним гостям частково, auth на ньому не ставлять, а розрізнення робить політика.
Тести для гостьових сценаріїв: перевірити, що гість бачить лише дозволене ($this->get(...)->assertOk() для опублікованого, assertForbidden()/assertNotFound() для чернетки), - саме тут найчастіше випадково відкривають приватні дані.
Проблема: рахунки, договори, вкладення листування лежать у сховищі, а посилання на них мають вигляд /storage/invoices/1041.pdf. Хто знає чи вгадає адресу, той завантажить файл - без перевірки прав.
Перше правило: приватні файли - не в публічній папці (public/, storage/app/public з посиланням storage:link), а на приватному диску. Віддає їх контролер з перевіркою прав:
Route::get('/invoices/{invoice}/download', function (Invoice $invoice) {
Gate::authorize('download', $invoice);
return Storage::disk('private')->download($invoice->path, "invoice-{$invoice->number}.pdf");
})->middleware('auth');
Підписані URL - коли файл треба віддати без входу (посилання в листі, вбудування в сторонній сервіс) або на обмежений час:
$url = URL::temporarySignedRoute(
'invoices.download',
now()->plus(hours: 24),
['invoice' => $invoice->id],
);
Route::get('/invoices/{invoice}/download', DownloadInvoiceController::class)
->name('invoices.download')
->middleware('signed');
- до URL додаються
expiresіsignature- HMAC від усієї адреси з ключем застосунку (APP_KEY); - зміна будь-якого параметра (
invoice=1042) чи терміну дії робить підпис недійсним -403; - після закінчення терміну посилання перестає працювати.
Що важливо розуміти:
- підписаний URL - це «пред'явницький» доступ: будь-хто з посиланням отримає файл. Переслане, збережене в історії, потрапило в лог - доступ має кожен. Тому: короткий термін дії, а для чутливих документів - підпис плюс перевірка входу й прав;
- ключ підпису -
APP_KEY: його зміна робить недійсними всі видані посилання; - за проксі (Cloudflare, балансувальник) адреса, яку бачить Laravel, може відрізнятися від підписаної (схема, хост) - тоді допомагає
signed:relativeі підпис відносного URL (absolute: false); - одноразовість підписані URL не забезпечують - для одноразових посилань потрібна позначка в базі.
Для файлів у S3/R2 аналог - Storage::temporaryUrl(): підписане посилання сховища, і файл віддається напряму з нього, минаючи сервер застосунку.
RBAC (Role-Based Access Control) - права видаються ролям, а ролі - користувачам.
editor → posts.create, posts.update, posts.publish
viewer → posts.view
Оля → editor
- просто пояснити, просто адмініструвати («зробити Іру редактором»);
- у Laravel -
spatie/laravel-permission, власні таблиці ролей і прав; - слабкість: погано виражає правила «залежно від об'єкта». «Редактор може редагувати лише статті свого відділу» - уже не чиста роль. Спроби втиснути це в RBAC породжують «вибух ролей» (
editor-marketing,editor-sales, ...).
ABAC (Attribute-Based Access Control) - рішення на основі атрибутів користувача, ресурсу, дії й контексту:
public function update(User $user, Document $document): bool
{
return $user->department_id === $document->department_id
&& $document->status !== 'archived'
&& $user->clearance >= $document->classification;
}
- гнучко: відділ, статус документа, рівень доступу, час, IP, країна;
- політики Laravel - це фактично ABAC у коді;
- слабкість: правила розкидані в коді, їх важче перевірити й пояснити («чому Оля бачить цей документ?»).
ReBAC (Relationship-Based Access Control) - доступ визначається зв'язками між об'єктами:
Оля - учасник команди «Маркетинг»
«Маркетинг» - власник папки «Кампанії 2026»
Документ - у папці «Кампанії 2026»
⇒ Оля може переглядати документ
- природно для спільного доступу: Google Drive, GitHub (організації, команди, репозиторії), Notion;
- модель Google Zanzibar і її реалізації (OpenFGA, SpiceDB) масштабують такі перевірки на мільярди зв'язків;
- слабкість: окрема інфраструктура, складність налагодження ланцюжків зв'язків.
На практиці - комбінація:
- RBAC для грубих можливостей («хто має доступ до адмінки», «хто може запрошувати учасників»);
- ABAC / перевірки в політиках для правил щодо конкретних об'єктів («автор може редагувати свою статтю, поки вона не опублікована»);
- ReBAC - коли продукт будується навколо спільного доступу з вкладеними групами й успадкуванням прав.
Головне - не модель, а дисципліна: усі перевірки в одному шарі (політики), кожна дія має правило, і є тести на заборону.
IDOR (Insecure Direct Object Reference) - застосунок використовує ідентифікатор від клієнта, щоб знайти об'єкт, і не перевіряє, чи має користувач до нього доступ. Ендпойнти з {id} в адресі зазвичай перевіряють. Вразливості частіше ховаються в менш помітних місцях.
1. Ідентифікатори в тілі запиту й прихованих полях:
<input type="hidden" name="account_id" value="15">
Transfer::create(['from_account_id' => $request->account_id, ...]); // рахунок не перевірено
Прихований чи disabled у формі - не захищений: його змінюють у DevTools.
2. Завантаження й перегляд файлів: /download?file=invoices/1041.pdf, /attachments/88/preview, прямі посилання на файли в публічному сховищі.
3. Експорт і звіти: /export?user_ids[]=5&user_ids[]=6 - перевіряють права на експорт загалом, але не на кожен переданий ідентифікатор.
4. Масові дії: «видалити обрані» отримує масив id - перевірка лише першого чи жодного.
5. Фільтри списків: /orders?customer_id=7 - список фільтрується за параметром, а не обмежується правами користувача.
6. Пов'язані об'єкти при створенні: POST /comments {"post_id": 42} - коментар до приватного поста, до якого немає доступу; {"team_id": 9} - додати себе в чужу команду.
7. Livewire й інші компоненти зі станом: ідентифікатор у публічній властивості, який змінюють у браузері (без #[Locked] чи моделі у властивості).
8. GraphQL і вкладені поля: доступ до об'єкта перевірено, а до вкладених зв'язків (user { orders { ... } }) - ні.
9. Адмінки й службові панелі: перевірено, що користувач - адміністратор, але не що він адміністратор цієї організації.
Як закрити системно:
- шукати через власника чи область видимості:
$user->accounts()->findOrFail($id),$team->members()->...; - валідація з умовою власності:
Rule::exists('accounts', 'id')->where('user_id', $user->id); - політика для кожного об'єкта, отриманого з ідентифікатора, - включно з масивами (
foreach ($ids ...)чи один запит з умовою власності); - глобальні області видимості для багатоорендних даних - щоб чужі записи не потрапляли в запити за замовчуванням.
Непередбачувані ідентифікатори (UUID) ускладнюють перебір, але не замінюють перевірку: ідентифікатори «протікають» через URL, листи й логи.
Gate::before реєструє колбек, який виконується перед усіма перевірками гейтів і політик. Поширений прийом - суперадміністратор, якому дозволено все:
Gate::before(function (User $user, string $ability) {
if ($user->isSuperAdmin()) {
return true;
}
});
Як працює результат:
true- дозвіл, жодні гейти й політики далі не викликаються;false- заборона, теж без подальших перевірок;null(нічого не повертати) - звичайна перевірка продовжується.
Пастки:
1. return false замість «нічого».
Gate::before(fn (User $user) => $user->isSuperAdmin()); // помилка!
Для звичайного користувача стрілкова функція повертає false - і всі перевірки в застосунку стають забороненими. Колбек має повертати true або null.
2. Суперадмін може «все» - справді все. true з before дозволяє і дії, які мали б бути заборонені для всіх за бізнес-правилами: видалити оплачене замовлення, змінити закритий фінансовий період, редагувати документ, підписаний іншою стороною. Якщо такі обмеження є, їх варто перевіряти поза гейтами (у сервісі, доменному об'єкті) або виключати в before:
Gate::before(function (User $user, string $ability) {
if ($user->isSuperAdmin() && ! in_array($ability, ['delete-paid-order', 'impersonate'], true)) {
return true;
}
});
3. Ризик облікового запису. Обліковий запис суперадміністратора - найцінніша мішень: обов'язкова двофакторна автентифікація, мінімум таких користувачів, журнал усіх дій.
4. Ознака «суперадміністратора» має бути надійною: поле в базі, яке не змінюється через масове призначення, а не email чи ім'я, що їх можна підробити в іншому контексті.
before у політиці - те саме, але лише для однієї моделі:
class PostPolicy
{
public function before(User $user, string $ability): ?bool
{
return $user->is_moderator ? true : null;
}
}
Особливість: метод before політики не викликається, якщо в політиці немає методу з назвою перевірюваної дії.
Gate::after - виконується після перевірки; його результат враховується, лише якщо основна перевірка повернула null. Зручно для аудиту рішень.
Тест, який варто мати: звичайний користувач отримує дозвіл на свої дії (захист від пастки з false) і відмову на чужі.
Докладніше в документації: Laravel: перехоплення перевірок гейтів
Вразливості бізнес-логіки - кожен запит технічно коректний (правильні типи, валідний токен, без ін'єкцій), але послідовність чи комбінація дій дає результат, якого бізнес не передбачав. Автоматичні сканери шукають відомі шаблони (XSS, SQLi) і не знають правил вашого бізнесу - тому такі помилки знаходять люди.
Типові приклади:
1. Від'ємні й граничні значення:
POST /cart/items {"product_id": 5, "quantity": -3}
Від'ємна кількість зменшує суму замовлення чи повертає гроші на баланс. Те саме з нульовими цінами, дробовими кількостями, величезними числами (переповнення).
2. Довіра даним від клієнта: ціна, знижка чи сума доставки приходять у запиті й використовуються як є, замість обчислення на сервері.
3. Пропуск кроків процесу: оформлення замовлення «адреса → оплата → підтвердження». Запит відразу на крок підтвердження - і замовлення створено без оплати. Сервер має перевіряти стан процесу, а не вірити, що клієнт пройшов попередні кроки.
4. Купони й акції:
- повторне застосування одного купона;
- застосування після зміни кошика (купон «від 1000 грн» лишається після видалення товарів);
- поєднання несумісних знижок;
- реферальні бонуси за самого себе через другий обліковий запис.
5. Гонитва запитів: десять паралельних запитів «застосувати купон» чи «вивести кошти» проходять перевірку одночасно, до того як перший запише результат.
6. Зміна даних після перевірки: спершу підтвердили email, потім змінили на інший - і він вважається підтвердженим.
7. Обхід обмежень через альтернативний шлях: обмеження ліміту в інтерфейсі, але не в API; перевірка у веб-версії, але не в мобільному ендпойнті.
Як захищатися:
- сервер обчислює все важливе: ціни, знижки, суми, доставку - з бази, а не з запиту;
- валідація меж:
'quantity' => ['integer', 'min:1', 'max:100'], перевірка діапазонів для всіх числових полів; - явні стани й переходи для процесів (замовлення, оплата, верифікація) - перевірка поточного стану перед кожним кроком;
- атомарність операцій з балансами й лімітами (транзакції, блокування, унікальні обмеження);
- моделювання загроз для нових функцій: «як цим можна зловживати?» - разом з бізнесом.
Тести на зловживання («що буде, якщо кількість від'ємна», «що, якщо пропустити крок») корисні не менше, ніж тести щасливого шляху.
Адмін-панель дає доступ до даних усіх користувачів і до налаштувань системи - її компрометація зазвичай означає компрометацію всього застосунку. Тому захист будується в кілька шарів.
1. Сильна автентифікація:
- обов'язкова двофакторна автентифікація для всіх облікових записів з доступом до адмінки - краще ключі безпеки чи passkeys (стійкі до фішингу), ніж SMS;
- окремі облікові записи для кожної людини - без спільних «admin@company»;
- коротша сесія й повторне підтвердження пароля перед критичними діями (Laravel - middleware
password.confirm).
2. Мінімальні права:
- ролі в адмінці (підтримка, модератор, фінанси, суперадміністратор), а не «адмін може все»;
- політики на кожен ресурс і дію - як і в основному застосунку; Filament, Nova та подібні використовують політики Laravel;
- суперадміністраторів - мінімум.
3. Обмеження доступу на рівні мережі:
- окремий піддомен (
admin.example.com) - простіше обмежити й моніторити; - дозволений список IP чи доступ через VPN / Zero Trust (Cloudflare Access тощо) - адмінка невидима з інтернету;
- нестандартний шлях не є захистом - лише зменшує шум від сканерів.
4. Захист від типових атак:
- обмеження частоти спроб входу й блокування після серії невдач;
- CSRF-захист і
frame-ancestors 'none'(адмінки - улюблена мішень clickjacking); - суворий CSP - XSS в адмінці небезпечніший, ніж на публічних сторінках.
5. Журнал дій:
- хто, коли, що змінив, з якої IP - для всіх змін у адмінці;
- особливо - зміни ролей, прав, налаштувань безпеки, експорти даних;
- журнал недоступний для зміни самими адміністраторами;
- сповіщення про аномалії (масові експорти, вхід з нової країни).
6. Обережно з «увійти як користувач» (impersonation): дуже зручно для підтримки, але це повний доступ до облікового запису. Лише для обмеженої ролі, з журналом, явною позначкою в інтерфейсі й заборонами (не змінювати пароль, не робити платежі від імені користувача).
7. Оновлення: пакети адмінок (Filament, Nova, сторонні) - така сама частина ланцюжка постачання; оновлювати й стежити за бюлетенями безпеки.
Регулярна ревізія доступів: хто має доступ до адмінки зараз, і чи досі він потрібен (звільнені співробітники, підрядники, тестові облікові записи).
Докладніше в документації: OWASP: багатофакторна автентифікація
У багатоорендному (multi-tenant) застосунку дані різних клієнтів-компаній лежать в одних таблицях з колонкою tenant_id. Найгірший можливий інцидент - витік даних між орендарями, тож ізоляція має бути системною, а не «не забути where в кожному запиті».
Рівень застосунку - глобальна область видимості Eloquent:
#[ScopedBy(TenantScope::class)]
class Invoice extends Model {}
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where($model->qualifyColumn('tenant_id'), Tenant::current()->id);
}
}
Кожен Eloquent-запит до моделі автоматично обмежено поточним орендарем. Плюс автоматичне заповнення tenant_id при створенні.
Слабкі місця глобальних областей:
- не працюють поза Eloquent:
DB::table('invoices'), сирі запити, звіти, деякі пакети; withoutGlobalScopes()- один виклик знімає захист;- черги, команди, планувальник - немає HTTP-запиту, і «поточний орендар» має бути встановлений явно (інакше область або падає, або - гірше - не застосовується);
- зв'язки й підзапити (
whereHas,join) можуть обійти область для пов'язаних таблиць; - унікальні індекси й
exists-валідація безtenant_id- перевірка «email уже зайнятий» по всіх орендарях розкриває існування даних.
Рівень бази - Row-Level Security (RLS) у PostgreSQL:
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::bigint)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint);
DB::statement("SELECT set_config('app.tenant_id', ?, true)", [$tenant->id]); // true - лише в межах транзакції
Тепер будь-який запит - Eloquent, сирий SQL, звіт - бачить лише рядки поточного орендаря. Помилка в коді застосунку не перетвориться на витік.
Підводні камені RLS:
- власник таблиці й суперкористувачі обходять RLS за замовчуванням - потрібен
FORCE ROW LEVEL SECURITYі окрема роль застосунку без прав власника та безBYPASSRLS; - пули з'єднань (PgBouncer у режимі транзакцій, постійні з'єднання Octane): значення, встановлене для сесії, «перетікає» в наступний запит іншого орендаря. Тому -
set_config(..., true)/SET LOCALу транзакції на кожен запит; - міграції, адмінка й фонові задачі між орендарями потребують окремої ролі з явним обходом;
- продуктивність: умова політики додається до кожного запиту - потрібні індекси з
tenant_idпершою колонкою.
Підсумок: глобальна область - зручність і перший рівень, RLS - гарантія на рівні бази. Для даних, де витік між клієнтами неприпустимий, - обидва, плюс тести «орендар A не бачить даних орендаря B» для кожної моделі.
Докладніше в документації: PostgreSQL: Row Security Policies
TOCTOU (time-of-check to time-of-use) - між перевіркою умови й використанням її результату стан змінюється. У вебзастосунку це паралельні запити, кожен з яких проходить перевірку до того, як інший запише зміни.
// вразливо
public function withdraw(Request $request)
{
$account = $request->user()->account;
if ($account->balance >= $request->amount) { // перевірка
$account->decrement('balance', $request->amount); // використання
Payout::create([...]);
}
}
Нападник відправляє 20 однакових запитів одночасно. Усі читають баланс 1000, усі проходять перевірку, і рахунок отримує 20 виплат. Інструменти на кшталт Burp Suite відправляють такі «пачки» з точністю до мілісекунд, тож це не теоретична атака.
Де це трапляється:
- баланс, бонуси, подарункові картки, виведення коштів;
- одноразові купони й промокоди («використати один раз»);
- ліміти («до 3 безкоштовних проєктів», «один відгук на замовлення»);
- голосування й лайки;
- запрошення й одноразові посилання;
- перевірка прав, після якої права відкликано, а дія вже виконується.
Як захищатися:
1. Атомарна умова в самому запиті до бази:
$updated = Account::whereKey($account->id)
->where('balance', '>=', $amount)
->decrement('balance', $amount);
if ($updated === 0) {
throw new InsufficientFunds;
}
Перевірка й зміна - один оператор UPDATE ... WHERE balance >= ?. База гарантує, що паралельні запити не пройдуть обидва.
2. Блокування рядка в транзакції:
DB::transaction(function () use ($accountId, $amount) {
$account = Account::lockForUpdate()->findOrFail($accountId);
// поки транзакція не завершилась, інші lockForUpdate чекають
});
3. Унікальні обмеження - найнадійніше для «один раз»: унікальний індекс на (coupon_id, user_id) - другий запис просто не вставиться, хоч скільки паралельних запитів.
4. Атомарні блокування кешу - Cache::lock("payout:{$userId}", 10)->block(5, ...) для операцій, що зачіпають кілька систем.
5. Ключі ідемпотентності для операцій з грошима - повтор того самого запиту не створює другу операцію.
Чого недостатньо:
- перевірка в застосунку без блокування - вікно гонитви лишається;
- блокування в пам'яті процесу - на кількох серверах чи воркерах не працює;
- «малоймовірно» - нападник спеціально робить це ймовірним.
Тестування: паралельні запити в тестах (Http::pool, Concurrency::run) або навантажувальні інструменти - звичайні послідовні тести таких помилок не бачать.
Помилки авторизації рідко ловлять «звичайні» тести: розробник перевіряє, що власник може виконати дію, а те, що чужий користувач не може, - не перевіряється. Тому авторизацію тестують окремо й системно.
1. Матриця доступу. Для кожного ресурсу - таблиця «роль × дія → очікуваний результат»:
| гість | власник | інший користувач | модератор | адмін іншої організації | |
|---|---|---|---|---|---|
| переглянути | 404 | 200 | 404 | 200 | 404 |
| змінити | 401 | 200 | 403 | 403 | 404 |
| видалити | 401 | 200 | 403 | 200 | 404 |
Матриця - і документація, і основа тестів.
2. Датасети в Pest - один тест на всю матрицю:
dataset('post access', [
'guest' => [fn () => null, 'update', 401],
'owner' => [fn () => Post::first()->author, 'update', 200],
'stranger' => [fn () => User::factory()->create(), 'update', 403],
]);
it('enforces access to posts', function (Closure $actor, string $action, int $status) {
$post = Post::factory()->create();
$user = $actor();
$response = ($user ? $this->actingAs($user) : $this)
->putJson("/api/posts/{$post->id}", ['title' => 'Новий заголовок']);
$response->assertStatus($status);
if ($status !== 200) {
expect($post->fresh()->title)->not->toBe('Новий заголовок'); // стан не змінився
}
})->with('post access');
Перевіряти не лише статус, а й відсутність змін у базі: буває, що відповідь 403, а дія вже виконана.
3. Тест «кожен маршрут захищено». Пройтися по Route::getRoutes() і переконатися, що кожен маршрут має middleware auth чи явно позначений як публічний. Новий маршрут без авторизації ламає тест одразу:
it('protects every route', function () {
$public = ['login', 'register', 'password.request', 'home'];
collect(Route::getRoutes())
->reject(fn ($route) => in_array($route->getName(), $public, true))
->each(fn ($route) => expect($route->gatherMiddleware())->toContain('auth'));
});
4. Тести політик напряму - швидкі модульні тести для складних правил ($user->can('update', $post)), окремо від HTTP.
5. Багатоорендність: для кожної моделі - тест «орендар A не бачить даних орендаря B» на всіх шляхах: список, деталі, пошук, експорт, фільтри.
6. Ручне й автоматизоване тестування на продакшен-подібному середовищі: два облікові записи в браузері (чи розширення на кшталт Autorize для Burp), що повторюють запити одного користувача від імені іншого.
Процес: новий ендпойнт без тестів на заборону не проходить рев'ю коду - це найдешевший спосіб не допустити IDOR.
Звичайні логи застосунку відповідають на питання «що зламалося». Журнал аудиту відповідає на інші: хто, що, коли й до чого отримав доступ чи змінив. Без нього після інциденту неможливо з'ясувати масштаб витоку, а в разі спору - довести, хто виконав дію.
Які події записувати:
- автентифікація: успішні й невдалі входи, вихід, зміна пароля, увімкнення/вимкнення 2FA, скидання пароля;
- зміна прав: призначення ролей, запрошення в команду, видача й відкликання токенів;
- доступ до чутливих даних: перегляд персональних даних, експорт, завантаження документів;
- адміністративні дії: «увійти як користувач», зміна налаштувань безпеки, видалення даних;
- відмови в доступі (403) - серія відмов від одного користувача часто означає спробу перебору ідентифікаторів.
Структура запису:
{
"at": "2026-10-04T10:15:00Z",
"actor": {"type": "user", "id": 42, "impersonated_by": null},
"action": "invoice.downloaded",
"subject": {"type": "invoice", "id": 1041},
"tenant_id": 7,
"ip": "203.0.113.5",
"user_agent": "...",
"request_id": "9f2c...",
"result": "allowed"
}
Вимоги до журналу аудиту:
- незмінність: запис лише додається; адміністратори застосунку не можуть його змінити чи видалити. Окреме сховище чи таблиця з правами лише на вставку, експорт у зовнішню систему (SIEM);
- без секретів: паролі, токени, повні номери карток - ніколи;
- мінімізація персональних даних: записувати ідентифікатори, а не вміст документів; врахувати вимоги GDPR щодо зберігання й видалення;
- узгоджений час (UTC) і ідентифікатор запиту, щоб зв'язати з рештою логів;
- термін зберігання відповідно до регуляторних вимог.
У Laravel:
spatie/laravel-activitylog- журнал змін моделей і власних подій;- події автентифікації (
Login,Failed,Logout,PasswordReset) - слухачі, що пишуть у журнал; Gate::after- зручне місце, щоб фіксувати рішення авторизації для чутливих дій.
Журнал без моніторингу допомагає лише після інциденту. Сповіщення на аномалії - масовий експорт, вхід адміністратора з нової країни, сотні відмов у доступі за хвилину - дають змогу помітити атаку, поки вона триває.
Найдорожчі атаки часто не використовують жодної технічної вразливості: зловмисник просто викликає легітимну функцію у масштабі, на який бізнес не розраховував.
Типові сценарії:
- SMS-накрутка (SMS pumping, toll fraud): бот запитує коди підтвердження на тисячі номерів у дорогих напрямках. Оператор, з яким у змові зловмисник, ділиться з ним виручкою, а застосунок платить за кожне SMS - рахунок на тисячі доларів за ніч;
- промокоди й пробні періоди: тисячі реєстрацій для одноразової знижки чи безкоштовного тарифу;
- реферальні бонуси: ферми акаунтів запрошують одна одну;
- скупка дефіцитного товару ботами, автоматичне бронювання без оплати;
- збір даних: перебір публічних профілів чи цін конкурентами.
Чому звичайне обмеження частоти не допомагає: ліміт «5 запитів на хвилину з IP» обходиться ротацією тисяч IP з резидентних проксі, а ліміт на акаунт - створенням нових акаунтів.
Багаторівневий захист:
| Рівень | Що обмежує |
|---|---|
| IP і підмережа | грубі атаки з одного джерела |
| акаунт | дії одного користувача |
| ціль дії | кількість SMS на номер, на префікс країни, на напрямок |
| пристрій чи сесія | відбиток браузера, токен застосунку |
| глобальний бюджет | загальна кількість SMS на годину для всього сервісу |
RateLimiter::for('sms', function (Request $request) {
$phone = (string) $request->input('phone');
return [
Limit::perHour(3)->by('phone:' . $phone),
Limit::perHour(10)->by('ip:' . $request->ip()),
Limit::perHour(500)->by('sms:global'),
];
});
Що ще працює:
- дозволені країни для SMS - лише ті, де справді є користувачі; дорогі міжнародні напрямки вимкнені за замовчуванням;
- бюджети й сповіщення: ліміт витрат у провайдера SMS, алерт при стрибку кількості відправлень;
- дешевша альтернатива SMS: вхід за посиланням на email, TOTP, passkeys;
- тертя для ризикових дій: CAPTCHA чи Turnstile лише після підозрілих ознак, а не для всіх;
- бізнес-правила: промокод прив'язаний до верифікованої картки чи телефону, реферальний бонус - після першої оплати, а не реєстрації;
- затримка винагороди: бонус нараховується через кілька днів, коли шахрайство встигли виявити.
Моніторинг важливіший за правила: метрики на кожну дорогу дію (SMS, бонуси, пробні періоди) з порогами аномалій. Правила зловмисники обходять, а різкий стрибок графіка видно одразу.
Моделювання загроз для нової функції має включати питання «що буде, якщо цю дію викличуть мільйон разів» - разом з питаннями про доступ і валідацію.
Докладніше в документації: OWASP API6:2023 Unrestricted Access to Sensitive Business Flows