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

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

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

  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: шифрування

Щоб зупинити застосунок, не потрібні мільйони запитів: досить кількох, кожен з яких змушує сервер робити величезну роботу. Захист від мережевих атак (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