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

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

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

103 питань

APP_KEY - головний секрет застосунку. Ним Laravel шифрує й підписує дані, що залишають сервер чи зберігаються:

  • cookie: сесійна cookie й інші cookie, які шифрує middleware EncryptCookies;
  • сесії (якщо SESSION_ENCRYPT=true);
  • підписані URL (URL::signedRoute, посилання підтвердження email);
  • дані, зашифровані через Crypt та касти encrypted у моделях;
  • сторонні механізми, що на нього спираються (наприклад, підпис знімків стану Livewire).

Генерується командою php artisan key:generate (AES-256, 32 байти, у .env як base64:...).

Чому ключ не можна просто замінити: після зміни все, що зашифровано старим ключем, перестає розшифровуватися:

  • користувачі розлогінюються (cookie недійсні);
  • посилання з листів (підтвердження, скидання) перестають працювати;
  • дані в базі з кастом encrypted стають нечитабельними - це найнебезпечніше.

Плавна ротація: старі ключі перелічуються в APP_PREVIOUS_KEYS:

APP_KEY=base64:новий...
APP_PREVIOUS_KEYS=base64:старий...

Laravel шифрує лише новим ключем, а розшифровує - пробуючи поточний і попередні. Користувачі не розлогінюються, старі посилання діють.

Повна ротація (наприклад, після витоку ключа):

  1. новий ключ у APP_KEY, старий - у APP_PREVIOUS_KEYS;
  2. перешифрувати дані в базі новим ключем (команда, що читає й зберігає кожне зашифроване поле);
  3. через час, достатній для завершення сесій і закінчення терміну посилань, прибрати старий ключ з APP_PREVIOUS_KEYS.

Правила зберігання:

  • у .env чи менеджері секретів, ніколи в репозиторії;
  • різні ключі для середовищ (локальне, тестове, продакшен) - інакше cookie з тестового стенда підходять до продакшену;
  • однаковий ключ на всіх серверах одного середовища - інакше сесії «ламаються» при переході між серверами за балансувальником;
  • при підозрі на витік - ротація і перешифрування.

Не плутати з хешуванням паролів: паролі хешуються (bcrypt/argon2) без APP_KEY - зміна ключа на них не впливає.

Докладніше в документації: Laravel: ротація ключів шифрування

Для полів, витік яких особливо шкідливий (токени сторонніх API, секрети 2FA, номери документів, медичні нотатки), Laravel має касти, що шифрують значення на рівні застосунку:

protected function casts(): array
{
    return [
        'two_factor_secret' => 'encrypted',
        'api_credentials' => 'encrypted:array',
        'medical_notes' => 'encrypted',
        'settings' => AsEncryptedArrayObject::class,
    ];
}

У базі зберігається зашифрований рядок (AES-256 з APP_KEY і MAC для перевірки цілісності). Модель автоматично шифрує при записі й розшифровує при читанні.

Від чого це захищає:

  • дамп чи бекап бази потрапив не в ті руки;
  • доступ до бази без доступу до застосунку (SQL-ін'єкція, що читає таблиці, скомпрометований обліковий запис адміністратора бази, репліка для аналітики);
  • логи запитів до бази містять лише шифротекст.

Від чого не захищає: від того, хто має доступ і до бази, і до APP_KEY (скомпрометований сервер застосунку), і від вразливостей у самому застосунку, що показують розшифровані дані.

Обмеження, про які треба знати:

  • не можна шукати й сортувати: where('passport_number', $value) не знайде запис - кожне шифрування дає різний шифротекст (випадковий IV). Якщо пошук потрібен - окрема колонка з «сліпим індексом»: HMAC від значення з окремим ключем, і пошук за ним;
  • не можна індексувати, агрегувати, використовувати в unique-обмеженнях бази;
  • розмір колонки: шифротекст (base64 з IV і MAC) значно довший за значення - колонка text, а не varchar(50);
  • ротація APP_KEY вимагає перешифрування всіх таких полів; без старого ключа в APP_PREVIOUS_KEYS дані втрачено;
  • продуктивність: розшифрування при кожному читанні - помітно на великих вибірках.

Окремий ключ для шифрування даних: можна вказати власний шифрувальник для моделей (Model::encryptUsing($encrypter)), щоб ключ даних був окремо від APP_KEY і ротувався незалежно.

Не шифрувати те, що має бути хешованим: паролі й одноразові токени - хеш (їх не потрібно розшифровувати), а шифрування - для даних, які застосунку треба прочитати у відкритому вигляді.

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

Підписаний URL містить параметр signature - HMAC від адреси, обчислений з APP_KEY. Будь-яка зміна URL (інший id, інший термін) робить підпис недійсним.

use Illuminate\Support\Facades\URL;

// безстроковий
$url = URL::signedRoute('unsubscribe', ['user' => $user->id]);

// з терміном дії
$url = URL::temporarySignedRoute('invoices.download', now()->plus(hours: 24), ['invoice' => $invoice->id]);
// https://example.com/invoices/42/download?expires=1760000000&signature=9a1f...

Перевірка на маршруті:

Route::get('/invoices/{invoice}/download', DownloadInvoice::class)
    ->name('invoices.download')
    ->middleware('signed');

Недійсний чи прострочений підпис - відповідь 403. Вручну - $request->hasValidSignature().

Де доречні:

  • відписка від розсилки одним кліком з листа - без входу в обліковий запис;
  • підтвердження email (так працює стандартна верифікація Laravel);
  • тимчасові посилання на файли - звіти, рахунки, експорт, які надсилаються поштою;
  • «чарівні посилання» для входу без пароля;
  • запрошення в команду чи проєкт.

Що важливо розуміти:

  • підпис захищає від зміни URL, а не від передачі. Хто має посилання, той має доступ. Тому для чутливих дій - короткий термін, а для «чарівних посилань» входу - ще й одноразовість (позначка в базі, що посилання використано);
  • підписується вся адреса з параметрами. Додатковий параметр, доданий після підписання (?utm_source=... від поштового сервісу), зламає підпис. Для таких випадків - hasValidSignatureWhileIgnoring(['utm_source']) або signed:relative для підпису лише шляху;
  • підпис залежить від APP_KEY і домену - зміна ключа без APP_PREVIOUS_KEYS зламає всі видані посилання; за проксі Laravel має знати правильну схему й хост (trustProxies), інакше перевірка https-посилання на http-запиті не пройде;
  • підписане посилання потрапляє в історію браузера, логи й поле Referer - не кладіть туди нічого, що має лишатися секретом довше за термін дії.

Для файлів на S3/R2 аналог - Storage::temporaryUrl(): підписує вже сховище, і файл віддається без участі застосунку.

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

Два типи файлів - два способи зберігання:

  • публічні (аватари, зображення статей) - диск public чи публічний бакет; доступні за прямим URL усім;
  • приватні (рахунки, документи, вкладення в листуванні) - диск local (каталог storage/app/private) чи приватний бакет; недоступні за прямим URL.

Типова помилка - класти все на public. php artisan storage:link робить storage/app/public доступним з вебу. Документ, збережений туди, відкривається будь-ким, хто вгадає чи отримає шлях - а шляхи потрапляють у листи, логи, історію браузера.

Віддача приватних файлів - через контролер з перевіркою прав:

Route::get('/documents/{document}', function (Document $document) {
    Gate::authorize('view', $document);

    return Storage::disk('private')->download($document->path, $document->original_name);
})->middleware('auth');

Для хмарних сховищ - тимчасове підписане посилання, файл віддає саме сховище:

return redirect(Storage::disk('s3')->temporaryUrl($document->path, now()->plus(minutes: 5)));

Правила збереження:

  • власні назви файлів - $file->store('documents') генерує випадкову назву. Оригінальну назву зберегти в базі для показу, але не використовувати як шлях (обхід шляху, перезапис чужих файлів, спецсимволи);
  • перевірка вмісту, а не розширення: правило mimes:pdf,jpg перевіряє MIME за вмістом; extensions: - лише розширення. Для зображень корисно ще й перекодувати (знімає вбудовані скрипти й метадані з геолокацією);
  • обмеження розміру - правило max: і ліміти PHP/вебсервера;
  • жодного виконання в каталогах завантажень: вебсервер не повинен виконувати .php з storage чи публічних каталогів;
  • Content-Disposition: attachment для файлів, які не повинні відкриватися в браузері (HTML чи SVG від користувача, відкриті як сторінка вашого домену, - XSS).

Видимість у хмарі: у Laravel/Flysystem файли за замовчуванням приватні; 'visibility' => 'public' - свідоме рішення. Бакет без публічного доступу на рівні налаштувань провайдера - додатковий захист від помилки в коді.

Видалення: при видаленні запису видаляти й файл (і навпаки - прибирати «осиротілі» файли), інакше дані, які користувач вважає видаленими, лишаються доступними.

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

Чутливі дані часто витікають не через злам бази, а через допоміжні системи: логи, звіти про помилки, інструменти налагодження, сторонні сервіси моніторингу.

Де вони з'являються:

  • стек винятку містить аргументи функцій - пароль, переданий у login($email, $password), опиниться у звіті про помилку;
  • контекст запиту в Sentry/Flare/Bugsnag - тіло запиту, заголовки (Authorization, Cookie);
  • власні логи - Log::info('Login attempt', $request->all());
  • Telescope і Debugbar - записують запити, SQL з параметрами, сесії;
  • серіалізовані моделі в логах і черзі - усі атрибути, крім $hidden.

Інструменти Laravel і PHP:

1. #[\SensitiveParameter] (PHP 8.2+) - значення параметра не потрапить у стек винятку:

public function attempt(string $email, #[\SensitiveParameter] string $password): bool

Laravel позначає так паролі у своїх методах автентифікації.

2. $hidden у моделях - атрибути, що не серіалізуються в масив і JSON (password, remember_token, two_factor_secret).

3. dontFlash у налаштуванні винятків - поля, які не повертаються в сесію після помилки валідації (за замовчуванням password, password_confirmation, current_password):

$exceptions->dontFlash(['card_number', 'cvv']);

4. Налаштування сервісів моніторингу - фільтри полів і заголовків перед відправкою (у Sentry - before_send, списки чутливих ключів).

5. Telescope на продакшені - вимкнений або з фільтрами (Telescope::hideRequestParameters([...]), hideRequestHeaders([...])) і доступом лише для адміністраторів.

Правила для власного логування:

  • логувати ідентифікатори, а не дані: user_id, order_id, а не email, адресу, номер картки;
  • ніколи $request->all() у лог;
  • структуровані логи з явним переліком полів - простіше перевірити, що туди потрапляє;
  • термін зберігання логів - персональні дані в них теж підпадають під вимоги захисту даних.

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

Перевірка: раз на якийсь час переглядати реальні записи логів і звітів про помилки очима - саме так знаходять «а тут, виявляється, повний JSON запиту з паролем».

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

Перелік дозволених доменів у script-src (script-src 'self' https://cdn.example.com) легко обходиться: на дозволеному CDN знайдеться бібліотека, яку можна використати для виконання довільного коду, або JSONP-ендпойнт. Тому сучасна рекомендація - строга CSP на основі nonce чи хешів.

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

Content-Security-Policy: script-src 'nonce-R4nd0mV4lue' 'strict-dynamic'; object-src 'none'; base-uri 'none'
<script nonce="R4nd0mV4lue" src="/build/app.js"></script>
<script nonce="R4nd0mV4lue">window.App = {...}</script>

Скрипт, вставлений через XSS, nonce не знає - і не виконається.

Хеш - для незмінних інлайнових скриптів: script-src 'sha256-...' з хешем вмісту скрипта. Зручно для статичних сторінок, де nonce генерувати нікому.

'strict-dynamic' - довіра поширюється на скрипти, які завантажив уже дозволений (з nonce) скрипт. Потрібно для сучасних збірок: застосунок динамічно підвантажує частини (import()), а знати їхні адреси заздалегідь неможливо. Браузери з підтримкою strict-dynamic ігнорують перелік доменів і 'self' у script-src.

Laravel і Vite. У middleware - згенерувати nonce, передати Vite і поставити заголовок:

public function handle(Request $request, Closure $next): Response
{
    $nonce = Vite::useCspNonce();   // генерує випадковий nonce, якщо не передано свій

    return $next($request)->withHeaders([
        'Content-Security-Policy' => "script-src 'nonce-{$nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none'",
    ]);
}

Після цього @vite додає nonce до всіх згенерованих тегів <script> і <link>. Для власних інлайнових скриптів - nonce="{{ Vite::cspNonce() }}".

Пастки:

  • nonce має бути новим на кожну відповідь. Сторінка з nonce, закешована на CDN, віддає всім той самий nonce - захист зникає;
  • обробники в атрибутах (onclick="...") nonce не підтримують - їх переносять у скрипти;
  • Livewire і Alpine обчислюють вирази директив через new Function(), що потребує 'unsafe-eval'. Без нього - CSP-збірка Alpine (у Livewire 4 - опція csp_safe), яка обмежує складні вирази в директивах.

Докладніше в документації: Laravel: CSP nonce для Vite

Строга CSP на великому сайті майже напевно щось зламає: інлайнові скрипти в шаблонах, onclick в атрибутах, сторонні віджети, аналітика, чат підтримки, стилі в атрибутах style. Вмикати її «одразу на продакшені» - ризиковано.

Режим лише звітування:

Content-Security-Policy-Report-Only: script-src 'nonce-...' 'strict-dynamic'; object-src 'none'; base-uri 'none'; report-to csp-endpoint
Reporting-Endpoints: csp-endpoint="https://example.com/csp-reports"

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

Процес впровадження:

  1. Інвентаризація: які скрипти, стилі, фрейми, fetch-ендпойнти реально використовуються (зокрема сторонніми сервісами).
  2. Політика в режимі Report-Only на продакшені.
  3. Аналіз звітів - збирати в окремий ендпойнт чи сервіс (Sentry, Report URI). Відфільтрувати шум: розширення браузера генерують багато порушень (chrome-extension://, moz-extension://), які до вашого коду не стосуються.
  4. Виправлення коду: інлайнові обробники - у файли скриптів, eval - прибрати, сторонні сервіси - додати до політики свідомо.
  5. Перемикання на блокування (Content-Security-Policy), а Report-Only лишити для наступної, суворішої версії політики.

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

Що варто врахувати:

  • ендпойнт звітів - публічний: обмеження частоти й розміру, без збереження довільних даних без перевірки (звіти може відправити будь-хто);
  • звіти містять URL сторінок - у них можуть бути токени й персональні дані;
  • CSP у <meta> працює для більшості директив, але не підтримує frame-ancestors, report-uri/report-to і режим Report-Only - для впровадження потрібен HTTP-заголовок;
  • різні політики для різних частин сайту (адмінка з багатьма віджетами й публічні сторінки) - нормальна практика.

Мета - не «щоб не було звітів», а щоб у script-src не лишилося 'unsafe-inline' і довгих переліків доменів.

Докладніше в документації: MDN: Content Security Policy

Subresource Integrity (SRI) - атрибут integrity з криптографічним хешем файлу. Браузер завантажує скрипт чи стиль, обчислює хеш і відмовляється виконувати файл, якщо хеш не збігся.

<script
  src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"
  integrity="sha384-..."
  crossorigin="anonymous"
  defer
></script>

Від чого захищає: від підміни файлу на сторонньому сервері. Якщо CDN зламали чи пакет на ньому підмінили, на сторінку підключиться не шкідливий код, а нічого - браузер заблокує файл з невідповідним хешем. Без SRI скомпрометований CDN отримує повний доступ до всіх сторінок, що його використовують.

Умови роботи:

  • crossorigin="anonymous" обов'язковий для файлів з іншого походження - браузер має завантажити файл з CORS, щоб мати право перевірити вміст. Без нього сторонній файл із SRI не завантажиться;
  • хеш прив'язаний до конкретного вмісту: посилання має вести на фіксовану версію (@3.14.9), а не на «останню» (@3, latest) - інакше оновлення файлу на CDN зламає сайт;
  • алгоритми - sha256, sha384, sha512; можна вказати кілька хешів.

Коли SRI має сенс:

  • файли зі сторонніх CDN - основний сценарій;
  • власний CDN чи об'єктне сховище, якщо їх компрометація - реальний ризик;
  • для файлів зі свого ж походження користі небагато: якщо сервер зламано, нападник змінить і HTML з хешами.

Коли SRI не допоможе:

  • скрипти, що змінюються (аналітика, тег-менеджери, віджети чату, що оновлюються постачальником) - хеш постійно ламатиметься. Для них - лише обмеження через CSP і довіра до постачальника;
  • скрипти, які сам довірений скрипт підвантажує динамічно, - SRI їх не охоплює.

У Laravel з Vite SRI для власних зібраних файлів додається плагіном vite-plugin-manifest-sri: Laravel бачить хеші в маніфесті й додає integrity до тегів @vite автоматично.

Найнадійніша альтернатива - не підключати сторонні CDN узагалі: зібрати залежності в свій бандл. Тоді SRI для них не потрібен, а кількість сторонніх доменів у CSP менша.

Докладніше в документації: MDN: Subresource Integrity

Permissions-Policy (раніше Feature-Policy) дозволяє сайту заборонити чи обмежити доступ до потужних можливостей браузера - для власних сторінок і для вбудованих фреймів.

Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(self), fullscreen=(self "https://player.example")
  • () - вимкнено для всіх, включно з самим сайтом;
  • (self) - лише для власного походження;
  • (self "https://...") - для себе й переліченого походження;
  • * - для всіх (рідко потрібно).

Що можна контролювати: камера, мікрофон, геолокація, повноекранний режим, платежі (Payment Request), USB, Bluetooth, MIDI, автовідтворення, датчики, буфер обміну, демонстрація екрана тощо.

Навіщо, якщо браузер і так запитує дозвіл у користувача:

  • принцип найменших можливостей: якщо сайт ніколи не використовує камеру, то навіть після XSS шкідливий скрипт не зможе попросити доступ до неї від імені вашого домену - запит буде заблоковано без діалогу;
  • вбудовані фрейми: реклама, віджети, сторонні сервіси у <iframe> не отримають доступу до можливостей, які ви не делегували;
  • захист довіри до домену: діалог «example.com хоче використовувати камеру» - від вашого імені. Сторонній код у фреймі не повинен мати змоги його викликати.

Делегування фрейму - атрибутом allow:

<iframe src="https://video.example/embed/42" allow="fullscreen; autoplay"></iframe>

Фрейм отримає лише ті можливості, які дозволено і заголовком сторінки, і атрибутом allow.

Практичний підхід:

  • типовий сайт без медіа - вимкнути камеру, мікрофон, геолокацію, платежі, USB й інші непотрібні можливості;
  • там, де можливість справді потрібна (відеодзвінки, мапа з геолокацією), - дозволити лише self і, можливо, конкретним доменам;
  • перевіряти після додавання нових функцій: заголовок, що вимикає геолокацію, тихо зламає нову кнопку «Знайти поруч».

Обмеження: це захист «в глибину», а не основний: якщо сайт уже має XSS, проблема серйозніша за доступ до камери. Але заголовок дешевий і закриває цілий клас зловживань.

Докладніше в документації: MDN: Permissions-Policy

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

Найнебезпечніша комбінація - віддзеркалення Origin разом з cookie:

# запит з https://attacker.io
Origin: https://attacker.io

# відповідь вразливого сервера
Access-Control-Allow-Origin: https://attacker.io
Access-Control-Allow-Credentials: true

Сервер повторює будь-яке походження з запиту й дозволяє передачу cookie. Сторінка нападника робить fetch('https://app.example/api/me', { credentials: 'include' }) з браузера жертви - і читає її дані, токени, персональну інформацію. Це фактично вимкнена політика того самого походження.

Типові помилки:

  • динамічне віддзеркалення Origin «щоб усе працювало»;
  • перевірка за підрядком чи «закінченням»: endsWith('example.com') пропустить attacker-example.com; str_contains($origin, 'example.com') - example.com.attacker.io. Регулярний вираз без екранування крапки (example.com = example + будь-який символ + com);
  • дозвіл походження null: його надсилають пісочні фрейми (sandbox), локальні файли, деякі редиректи - нападник легко його отримає;
  • довіра всім піддоменам (*.example.com) - XSS на будь-якому забутому піддомені стає доступом до основного API;
  • дозвіл http://-походжень для HTTPS-сайту - нападник у тій самій мережі може підмінити незахищену сторінку.

Access-Control-Allow-Origin: * з credentials браузер не прийме - тому розробники й починають віддзеркалювати Origin, щоб «обійти» обмеження. Сама * без cookie прийнятна для справді публічних даних.

Правильно:

  • явний перелік дозволених походжень, порівняння на точну рівність;
  • supports_credentials лише там, де справді потрібні cookie (власний SPA на іншому піддомені);
  • Vary: Origin, якщо відповідь залежить від походження, - інакше CDN закешує відповідь з дозволом для одного походження й віддасть іншим.

У Laravel CORS налаштовується в config/cors.php: allowed_origins - явні значення, allowed_origins_patterns - обережно з регулярними виразами, supports_credentials - лише за потреби.

Пам'ятайте: CORS не захищає сервер від запитів - він лише керує тим, чи може JavaScript прочитати відповідь. Простий POST із чужого сайту все одно дійде до сервера - для цього є CSRF-захист.

Докладніше в документації: PortSwigger: CORS

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: багатофакторна автентифікація

Питання з реальних технічних співбесід - 103 питання у 7 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.

Рівні
Junior 35 Middle 37 Senior 31

Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії