Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Контроль доступу

Питання з реальних співбесід з відповідями: 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 без пояснення, які саме права відсутні.

Тестування: для кожної дії - тест, що користувач без прав отримує відмову. Позитивні сценарії перевіряються природно під час розробки, а відсутність заборони помічають лише тоді, коли її шукають.

Докладніше в документації: OWASP: авторизація

Вертикальне підвищення привілеїв - звичайний користувач отримує можливості вищої ролі: адміністратора, модератора, менеджера.

Приклади:

  • адмінка доступна за адресою /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) потрібна для кожної ролі, включно з адміністраторами клієнтів.

Тест-матриця: для кожного ендпойнта - запит від анонімного користувача, від звичайного користувача-власника, від іншого користувача того самого рівня й від нижчої ролі. Очікувані відповіді - явно в тестах.

Докладніше в документації: PortSwigger: контроль доступу

Гейти (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() для чернетки), - саме тут найчастіше випадково відкривають приватні дані.

Докладніше в документації: Laravel: гості в авторизації

Проблема: рахунки, договори, вкладення листування лежать у сховищі, а посилання на них мають вигляд /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(): підписане посилання сховища, і файл віддається напряму з нього, минаючи сервер застосунку.

Докладніше в документації: Laravel: підписані URL

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 - коли продукт будується навколо спільного доступу з вкладеними групами й успадкуванням прав.

Головне - не модель, а дисципліна: усі перевірки в одному шарі (політики), кожна дія має правило, і є тести на заборону.

Докладніше в документації: NIST: Role Based Access Control

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, листи й логи.

Докладніше в документації: OWASP: запобігання IDOR

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'], перевірка діапазонів для всіх числових полів;
  • явні стани й переходи для процесів (замовлення, оплата, верифікація) - перевірка поточного стану перед кожним кроком;
  • атомарність операцій з балансами й лімітами (транзакції, блокування, унікальні обмеження);
  • моделювання загроз для нових функцій: «як цим можна зловживати?» - разом з бізнесом.

Тести на зловживання («що буде, якщо кількість від'ємна», «що, якщо пропустити крок») корисні не менше, ніж тести щасливого шляху.

Докладніше в документації: PortSwigger: логічні вразливості

Адмін-панель дає доступ до даних усіх користувачів і до налаштувань системи - її компрометація зазвичай означає компрометацію всього застосунку. Тому захист будується в кілька шарів.

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) або навантажувальні інструменти - звичайні послідовні тести таких помилок не бачать.

Докладніше в документації: PortSwigger: гонитва запитів

Помилки авторизації рідко ловлять «звичайні» тести: розробник перевіряє, що власник може виконати дію, а те, що чужий користувач не може, - не перевіряється. Тому авторизацію тестують окремо й системно.

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.

Докладніше в документації: OWASP Web Security Testing Guide

Звичайні логи застосунку відповідають на питання «що зламалося». Журнал аудиту відповідає на інші: хто, що, коли й до чого отримав доступ чи змінив. Без нього після інциденту неможливо з'ясувати масштаб витоку, а в разі спору - довести, хто виконав дію.

Які події записувати:

  • автентифікація: успішні й невдалі входи, вихід, зміна пароля, увімкнення/вимкнення 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 - зручне місце, щоб фіксувати рішення авторизації для чутливих дій.

Журнал без моніторингу допомагає лише після інциденту. Сповіщення на аномалії - масовий експорт, вхід адміністратора з нової країни, сотні відмов у доступі за хвилину - дають змогу помітити атаку, поки вона триває.

Докладніше в документації: OWASP: журналювання

Найдорожчі атаки часто не використовують жодної технічної вразливості: зловмисник просто викликає легітимну функцію у масштабі, на який бізнес не розраховував.

Типові сценарії:

  • 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