Middle: питання на співбесіді з теми «Безпека Laravel-застосунку»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
6 питань
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 запиту з паролем».
Щоб зупинити застосунок, не потрібні мільйони запитів: досить кількох, кожен з яких змушує сервер робити величезну роботу. Захист від мережевих атак (CDN, WAF) тут не допомагає - запити виглядають цілком легітимно.
Типові «дорогі» запити:
| Вектор | Приклад |
|---|---|
| необмежена пагінація | GET /api/orders?per_page=1000000 |
| важкі фільтри й сортування | сортування за колонкою без індексу, LIKE '%...%' по мільйонах рядків |
| великі тіла запитів | JSON на сотні мегабайтів, масив з мільйоном елементів |
| файли | гігабайтні завантаження, zip-бомба, «бомба декомпресії» в зображенні (крихітний PNG, що розпаковується в гігабайти пікселів) |
| регулярні вирази | катастрофічний бектрекінг на рядку від користувача (ReDoS) |
| дорогі операції без ліміту | генерація PDF, експорт, виклик LLM чи платного API на кожен запит |
| хешування | дуже довгі паролі з дорогим алгоритмом хешування |
Захист у Laravel:
// пагінація: верхня межа
$perPage = min($request->integer('per_page', 20), 100);
// валідація розмірів і кількостей
$request->validate([
'items' => ['required', 'array', 'max:100'],
'items.*.sku' => ['required', 'string', 'max:64'],
'avatar' => ['image', 'max:2048', 'dimensions:max_width=4000,max_height=4000'],
'password' => ['required', 'string', 'max:128'],
]);
- сортування й фільтри лише зі списку дозволених колонок (які мають індекси);
- обмеження частоти для дорогих маршрутів окремо:
RateLimiter::for('exports', fn (Request $r) => Limit::perMinute(3)->by($r->user()->id)); - важка робота - у черзі, з окремим пулом воркерів, щоб експорт не забирав процеси вебзапитів;
- тайм-аути:
statement_timeout/max_execution_timeдля бази, тайм-аути HTTP-клієнта до сторонніх сервісів.
Межі на рівні інфраструктури:
client_max_body_sizeу Nginx,post_max_sizeіupload_max_filesizeу PHP - тіло відкидається ще до Laravel;memory_limitіmax_execution_time- щоб один запит не з'їв увесь сервер;- кількість воркерів PHP-FPM - обмежений ресурс: повільні запити займають їх, і решта користувачів чекає в черзі.
Неочевидні місця:
- розпакування архівів - перевіряти сумарний розмір і кількість файлів під час розпакування, а не за розміром архіву;
- зображення - перевіряти розміри в пікселях до обробки, а не лише розмір файлу;
- GraphQL і вкладені
includeв API - обмеження глибини й складності запиту.
Перевірка: у тестах навмисно надсилати граничні значення (per_page=100000, масив на 10 000 елементів) і очікувати 422, а не 500 і не хвилину очікування.
Докладніше в документації: OWASP API4:2023 Unrestricted Resource Consumption