Питання на співбесіді з Безпека
Питання з реальних співбесід з відповідями: 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 шифрує лише новим ключем, а розшифровує - пробуючи поточний і попередні. Користувачі не розлогінюються, старі посилання діють.
Повна ротація (наприклад, після витоку ключа):
- новий ключ у
APP_KEY, старий - уAPP_PREVIOUS_KEYS; - перешифрувати дані в базі новим ключем (команда, що читає й зберігає кожне зашифроване поле);
- через час, достатній для завершення сесій і закінчення терміну посилань, прибрати старий ключ з
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 і ротувався незалежно.
Не шифрувати те, що має бути хешованим: паролі й одноразові токени - хеш (їх не потрібно розшифровувати), а шифрування - для даних, які застосунку треба прочитати у відкритому вигляді.
Підписаний 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(): підписує вже сховище, і файл віддається без участі застосунку.
Два типи файлів - два способи зберігання:
- публічні (аватари, зображення статей) - диск
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' - свідоме рішення. Бакет без публічного доступу на рівні налаштувань провайдера - додатковий захист від помилки в коді.
Видалення: при видаленні запису видаляти й файл (і навпаки - прибирати «осиротілі» файли), інакше дані, які користувач вважає видаленими, лишаються доступними.
Чутливі дані часто витікають не через злам бази, а через допоміжні системи: логи, звіти про помилки, інструменти налагодження, сторонні сервіси моніторингу.
Де вони з'являються:
- стек винятку містить аргументи функцій - пароль, переданий у
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 запиту з паролем».
Перелік дозволених доменів у 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), яка обмежує складні вирази в директивах.
Строга 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 з адресою - досі широко підтримується, тож часто задають обидві.
Процес впровадження:
- Інвентаризація: які скрипти, стилі, фрейми,
fetch-ендпойнти реально використовуються (зокрема сторонніми сервісами). - Політика в режимі Report-Only на продакшені.
- Аналіз звітів - збирати в окремий ендпойнт чи сервіс (Sentry, Report URI). Відфільтрувати шум: розширення браузера генерують багато порушень (
chrome-extension://,moz-extension://), які до вашого коду не стосуються. - Виправлення коду: інлайнові обробники - у файли скриптів,
eval- прибрати, сторонні сервіси - додати до політики свідомо. - Перемикання на блокування (
Content-Security-Policy), а Report-Only лишити для наступної, суворішої версії політики.
Можна мати обидва заголовки одночасно: чинна політика блокує, а нова, суворіша, - лише звітує. Так політику посилюють поступово.
Що варто врахувати:
- ендпойнт звітів - публічний: обмеження частоти й розміру, без збереження довільних даних без перевірки (звіти може відправити будь-хто);
- звіти містять URL сторінок - у них можуть бути токени й персональні дані;
- CSP у
<meta>працює для більшості директив, але не підтримуєframe-ancestors,report-uri/report-toі режим Report-Only - для впровадження потрібен HTTP-заголовок; - різні політики для різних частин сайту (адмінка з багатьма віджетами й публічні сторінки) - нормальна практика.
Мета - не «щоб не було звітів», а щоб у script-src не лишилося 'unsafe-inline' і довгих переліків доменів.
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 менша.
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, проблема серйозніша за доступ до камери. Але заголовок дешевий і закриває цілий клас зловживань.
Політика того самого походження не дає 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-захист.
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: багатофакторна автентифікація
Питання з реальних технічних співбесід - 103 питання у 7 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії