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

Питання на співбесіді: Безпека Laravel-застосунку

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

15 питань

{{ $value }} - виводить значення з екрануванням HTML (через htmlspecialchars). Символи <, >, ", ', & стають сутностями, і рядок <script> показується як текст.

<p>{{ $comment->body }}</p>   {{-- безпечно --}}

{!! $value !!} - виводить як є, без екранування:

<div>{!! $comment->body !!}</div>   {{-- XSS, якщо body від користувача --}}

Доречно лише для HTML, який згенеровано вашим кодом чи пройшов санітизацію (наприклад, Markdown, перетворений у HTML з білим списком тегів).

Де екранування {{ }} не допомагає:

1. Атрибути з URL:

<a href="{{ $user->website }}">Сайт</a>

Значення екрановане, але javascript:alert(1) - валідний URL без жодного спецсимволу HTML. Посилання з даних користувача треба перевіряти на схему (http/https).

2. Всередині <script>:

<script>
    const name = '{{ $user->name }}';   {{-- ламається на лапках і переносах рядків --}}
</script>

Екранування для HTML не те саме, що для JavaScript. Для передачі даних у скрипт - Js::from() чи @js:

<script>
    const user = {{ Js::from($user->only('id', 'name')) }};
</script>

<div x-data="{ settings: @js($settings) }">

Js::from кодує значення в JSON з екрануванням, безпечним для вставки в HTML.

3. Атрибути подій і стилі: onclick="{{ ... }}", style="{{ ... }}" - контексти JavaScript і CSS. Дані користувача туди не вставляти.

4. Компоненти, що самі вставляють HTML: {!! $slot !!} у власних компонентах, ->toHtml(), HtmlString - обходять екранування.

Подвійне кодування: {{ }} за замовчуванням не кодує вже закодовані сутності двічі (&amp; лишиться &amp;); Blade::withoutDoubleEncoding() керує цим.

Правило для рев'ю коду: кожен {!! !!} має бути обґрунтований - звідки береться HTML і хто гарантує, що він безпечний.

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

Масове призначення - заповнення моделі масивом: User::create($data), $user->update($data), $user->fill($data). Якщо масив прийшов із запиту без фільтрації, користувач може змінити поля, яких у формі немає:

$user->update($request->all());
// POST name=Оля&is_admin=1   →   користувач став адміністратором

$fillable - білий список полів, які можна призначати масово:

class User extends Authenticatable
{
    protected $fillable = ['name', 'email', 'password'];
}

Поля поза списком при масовому призначенні мовчки ігноруються.

$guarded - чорний список: усе, крім указаних. protected $guarded = []; вимикає захист повністю.

Чому $fillable безпечніший: нова колонка в таблиці (is_admin, balance, email_verified_at) за $guarded автоматично стає доступною для масового призначення, а за $fillable - ні, доки її свідомо не додадуть.

Сучасний варіант - атрибути класу:

#[Fillable(['name', 'email', 'password'])]
class User extends Authenticatable {}

Головний захист - не $fillable, а валідація:

$user->update($request->validated());

validated() повертає лише поля, для яких є правила. $fillable - друга лінія оборони на випадок, коли хтось передасть $request->all().

Поля, які змінюються лише за правами (роль, статус модерації, баланс), не повинні бути у $fillable взагалі - їх змінює окремий код з перевіркою прав:

$user->forceFill(['role' => 'editor'])->save();   // явно, у коді адміністратора

Корисна перевірка в розробці:

Model::preventSilentlyDiscardingAttributes(! app()->isProduction());

Тепер спроба масово призначити поле поза $fillable кидає виняток замість мовчазного ігнорування - помилки в коді помітні одразу, а не в продакшені.

Model::unguard() вимикає захист глобально - допустимо в сидерах, але не в коді, що обробляє запити.

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

У режимі налагодження Laravel на будь-яку помилку показує детальну сторінку: повідомлення винятку, стек викликів з файлами й рядками, фрагменти коду, параметри запиту. Для API - те саме в JSON (exception, file, line, trace).

Що з цього отримує зловмисник:

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

Помилку викликати легко: некоректний параметр, зайвий символ в URL, неочікуваний тип у JSON. Сканери в інтернеті цілеспрямовано шукають сторінки налагодження Laravel.

Історичний приклад важкості: у старих версіях Ignition (сторінки помилок Laravel) була вразливість, що в режимі налагодження давала виконання коду на сервері (CVE-2021-3129). Режим налагодження на продакшені перетворив її з «теоретичної» на масово експлуатовану.

Правила:

  • APP_DEBUG=false і APP_ENV=production на продакшені - завжди;
  • перевірка при деплої: скрипт розгортання чи health-check, що зупиняється, якщо APP_DEBUG увімкнено в продакшені;
  • php artisan about показує поточний режим;
  • інструменти розробки (Telescope, Debugbar, Horizon, Pulse, Log Viewer) на продакшені - вимкнені або закриті авторизацією (Gate::define('viewTelescope', ...)). Debugbar на продакшені розкриває SQL-запити, сесії й заголовки кожного запиту;
  • сторінки помилок - власні, без деталей (resources/views/errors/500.blade.php), а деталі - в логах і системі моніторингу (Sentry, Flare).

Для налагодження продакшену - логи й моніторинг помилок з контекстом запиту, а не тимчасове ввімкнення APP_DEBUG («на хвилинку» - досить, щоб сканер його помітив).

Пов'язані витоки з тієї ж категорії: доступні через вебсервер .env, .git, storage/logs, phpinfo(). Корінь вебсервера має вказувати на public/, а не на корінь проєкту.

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

CSRF - сторонній сайт змушує браузер жертви надіслати запит на ваш сайт; браузер додає cookie сесії, і запит виконується від імені користувача.

Як Laravel захищає: middleware групи web (у Laravel 13 - PreventRequestForgery) перевіряє кожен запит, що змінює стан (POST, PUT, PATCH, DELETE). GET, HEAD, OPTIONS не перевіряються - тому вони й не повинні нічого змінювати.

Два способи довести, що запит зі «свого» сайту:

1. Заголовок Sec-Fetch-Site. Сучасні браузери самі додають його до запитів: same-origin, same-site чи cross-site. Підробити його зі стороннього сайту неможливо. Laravel 13 пропускає запит з Sec-Fetch-Site: same-origin без перевірки токена.

2. CSRF-токен - для браузерів і запитів без цього заголовка:

<form method="POST" action="/profile">
    @csrf
    ...
</form>

@csrf додає приховане поле _token. Для JavaScript - заголовок X-CSRF-TOKEN (з мета-тегу) або X-XSRF-TOKEN (з cookie XSRF-TOKEN; axios надсилає його автоматично). Невідповідність - помилка 419.

Налаштування в bootstrap/app.php:

->withMiddleware(function (Middleware $middleware): void {
    $middleware->preventRequestForgery(
        except: ['webhooks/stripe', 'webhooks/github/*'],
        // originOnly: true      - покладатися лише на Sec-Fetch-Site
        // allowSameSite: true   - дозволити запити з піддоменів того ж сайту
    );
})

Коли виключення доречне - запити, що не автентифікуються cookie-сесією:

  • вебхуки від платіжних систем і сервісів - вони не мають вашого токена, а захищаються підписом запиту, який обов'язково перевіряти;
  • маршрути API з автентифікацією токеном (routes/api.php і так не в групі web).

Коли виключення - помилка:

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

Додатковий шар - SameSite-cookie: сесійна cookie Laravel за замовчуванням SameSite=Lax - браузер не надсилає її у міжсайтових POST-запитах. Це сильно знижує ризик CSRF, але не замінює токен: Lax пропускає навігаційні GET, а старі браузери й піддомени - окремі випадки.

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

$request->all() повертає все, що надіслав клієнт: поля форми, параметри запиту, зайві поля, яких у формі немає. Передавати це в модель чи логіку - довіряти клієнту.

$request->validated() (чи результат $request->validate([...])) - лише ті поля, для яких описано правила, і лише після успішної перевірки:

public function update(UpdateProfileRequest $request)
{
    $request->user()->update($request->validated());
}

Зайве поле is_admin=1 у запиті просто не потрапить в update().

$request->safe() - перевірені дані як об'єкт з корисними методами:

$request->safe()->only(['name', 'email']);
$request->safe()->except(['password']);
$request->safe()->merge(['user_id' => $request->user()->id]);

Чому валідація - це безпека, а не лише зручність:

  • типи: integer, string, array, boolean гарантують, що далі код отримає очікуваний тип. JSON може надіслати масив замість рядка чи true замість числа - і код поведеться несподівано (нестрогі порівняння, помилки, обхід перевірок);
  • межі: max для рядків і масивів захищає від мегабайтних полів і масивів на мільйон елементів;
  • білі списки: Rule::in([...]) для статусів, сортування, ролей - значення поза списком не пройдуть;
  • існування й належність: Rule::exists('categories', 'id')->where('team_id', $teamId) - обрана категорія існує й належить команді користувача.

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

  • валідація є, але далі використовується $request->input('field') для поля, якого немає в правилах, - воно не перевірене;
  • $request->only([...]) без валідації - фільтрує поля, але не перевіряє значення;
  • правила sometimes / nullable без required там, де поле обов'язкове для безпеки;
  • валідація лише на фронтенді - запит можна надіслати напряму.

Form Request поєднує валідацію з авторизацією (authorize()) і робить контролер чистим - а validated() у ньому стає звичкою за замовчуванням.

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

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

APP_KEY часто сприймають як «ще одну змінну в .env», хоча його витік - один з найсерйозніших інцидентів для Laravel-застосунку.

Що дає ключ зловмиснику:

1. Розшифрування даних. Усе, що зашифровано Crypt чи кастами encrypted: токени сторонніх API, секрети 2FA, персональні дані - якщо зловмисник має й дамп бази.

2. Підробка cookie й сесій. Cookie Laravel шифруються й підписуються ключем. З ключем можна створити валідну зашифровану cookie з довільним вмістом. Наслідки залежать від конфігурації:

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

3. Підробка підписаних URL: підтвердження email для чужих адрес, «чарівні посилання» входу, одноразові посилання на файли, відписки - усе, що захищено signed.

4. Підробка даних сторонніх механізмів, що підписують дані ключем застосунку: наприклад, знімки стану Livewire - контрольна сума перестає захищати від зміни стану компонента.

Як ключі витікають:

  • .env у репозиторії (включно з історією Git), у Docker-образі, в архіві бекапу;
  • .env доступний з вебу через неправильний корінь вебсервера;
  • ключ з прикладів і туторіалів, який скопіювали в продакшен;
  • сторінка налагодження чи phpinfo() з змінними оточення;
  • логи CI/CD, що вивели змінні оточення.

Реагування на витік:

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

Профілактика: секрети поза репозиторієм і образами (змінні оточення, менеджер секретів, env:encrypt), корінь вебсервера на public/, сканування репозиторіїв на секрети.

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

Довірені проксі. Якщо перед застосунком стоїть балансувальник чи CDN (Cloudflare, AWS ALB, Nginx), справжні IP клієнта, протокол і хост приходять у заголовках X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host. Laravel використовує їх, лише якщо проксі довірений:

->withMiddleware(function (Middleware $middleware): void {
    $middleware->trustProxies(at: ['10.0.0.0/8']);       // адреси своїх балансувальників
    // $middleware->trustProxies(at: '*');                // лише якщо застосунок недоступний напряму
})

Без налаштування: $request->ip() повертає IP балансувальника - ліміти частоти діють на всіх клієнтів разом, логи марні; $request->secure() - false, і генеруються http:// посилання.

З at: '*', коли застосунок доступний і напряму: будь-хто надсилає X-Forwarded-For: 1.2.3.4 - і обходить ліміти частоти за IP, підробляє адреси в журналах аудиту.

Довірені хости. TrustHosts у Laravel за замовчуванням вимкнений - застосунок приймає запити з будь-яким заголовком Host, якщо вебсервер їх пропускає.

Отруєння посилання скидання пароля (host header injection):

  1. зловмисник надсилає запит на скидання пароля для email жертви з підробленим заголовком Host: evil.example;
  2. якщо URL у листі будується з хоста поточного запиту, жертва отримує справжній лист від вашого сервісу з посиланням https://evil.example/reset-password/{token};
  3. жертва переходить - токен потрапляє до зловмисника, і він скидає пароль.

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

Захист:

$middleware->trustHosts(at: ['laravelukraine.com'], subdomains: true);
  • trustHosts() - запити з іншим Host відхиляються;
  • вебсервер приймає лише свої домени (без «default server», що обслуговує будь-який хост);
  • посилання в листах і фонових задачах - з APP_URL (URL::forceRootUrl(config('app.url')) для черг і консолі, де запиту немає);
  • URL::forceScheme('https') на продакшені за проксі, якщо схему не можна надійно визначити.

Перевірка: curl -H 'Host: evil.example' https://ваш-сайт/forgot-password - відповідь має бути помилкою, а не нормальною сторінкою.

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

Налаштування в config/session.php (і змінні SESSION_* у .env) безпосередньо визначають, наскільки легко викрасти чи підробити сесію.

Атрибути cookie:

Параметр Безпечне значення Навіщо
secure (SESSION_SECURE_COOKIE) true на продакшені cookie лише через HTTPS
http_only true (за замовчуванням) недоступна JavaScript - XSS не вкраде сесію
same_site lax (за замовчуванням) чи strict не надсилається в міжсайтових POST - захист від CSRF
domain null чи конкретний домен cookie для .example.com бачать усі піддомени, включно з тими, що можуть бути скомпрометовані
partitioned для вбудованих сторінок у сторонніх сайтах CHIPS - окреме сховище для кожного сайту-власника

SESSION_SECURE_COOKIE за замовчуванням не задано - Laravel не виставляє Secure, доки ви не ввімкнете. На продакшені з HTTPS - обов'язково true.

Драйвер сесій:

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

encrypt (SESSION_ENCRYPT) - шифрувати дані сесії у сховищі. Корисно, якщо в сесії бувають чутливі дані, а доступ до Redis чи таблиці сесій мають інші системи.

Терміни:

  • lifetime - хвилини неактивності до завершення сесії (за замовчуванням 120);
  • expire_on_close - сесія закінчується при закритті браузера;
  • для адмінок і фінансових застосунків - коротші терміни й повторне підтвердження пароля для чутливих дій.

Регенерація ідентифікатора:

  • після входу - $request->session()->regenerate() (стандартні контролери автентифікації роблять це) - захист від фіксації сесії;
  • при виході - invalidate() і regenerateToken().

Що ще перевірити:

  • окремі назви cookie (SESSION_COOKIE) для різних застосунків на одному домені - інакше вони перезаписують сесії одне одного;
  • SameSite=None (потрібна для вбудовування в iframe на інших сайтах) - лише з Secure і з усвідомленням, що захист від CSRF тепер повністю на токенах;
  • таблиця сесій містить IP і user agent - персональні дані з відповідним терміном зберігання.

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

Laravel хешує паролі через Hash::make() (і каст hashed у моделі User). Налаштування - у config/hashing.php.

Алгоритм за замовчуванням - bcrypt з вартістю (BCRYPT_ROUNDS) 12: кожне збільшення на 1 подвоює час обчислення. Альтернатива - Argon2id (HASH_DRIVER=argon2id) з параметрами пам'яті, часу й потоків (ARGON_MEMORY, ARGON_TIME, ARGON_THREADS).

bcrypt чи Argon2id:

  • Argon2id - сучасний рекомендований OWASP варіант: вимагає багато пам'яті, тому перебір на GPU й спеціалізованих чипах значно дорожчий;
  • bcrypt - перевірений часом, доступний усюди, але має обмеження в 72 байти: усе, що довше, ігнорується. Пароль з 80 символів і той самий пароль з іншими останніми символами дадуть однаковий хеш. Для кирилиці (2 байти на символ) межа - ~36 символів. Laravel має параметр BCRYPT_LIMIT, щоб відхиляти задовгі паролі замість мовчазного обрізання.

Як обирати вартість: обчислення хешу має займати приблизно сотні мілісекунд на вашому сервері. Менше - легше перебирати при витоку бази; більше - повільний вхід і можливість DoS через масові спроби входу. Вартість варто переглядати разом з обладнанням.

Повторне хешування при вході. У Laravel 11+ rehash_on_login увімкнено за замовчуванням: при успішному вході Laravel перевіряє, чи хеш створено з поточними налаштуваннями, і якщо ні - перехешовує пароль (відкритий текст на цей момент відомий). Тому підвищення BCRYPT_ROUNDS чи перехід на Argon2id відбуваються поступово, без примусового скидання паролів.

verify (HASH_VERIFY=true) - перевірка, що хеш створено очікуваним алгоритмом. Захищає від ситуацій, коли в базі опиняються хеші іншого типу (наприклад, після імпорту), що перевірялися б іншим, можливо слабшим, способом.

Ручне перехешування:

if (Hash::needsRehash($user->password)) {
    $user->password = Hash::make($plainPassword);
    $user->save();
}

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

  • md5, sha1, sha256 для паролів - швидкі хеші, мільярди перевірок за секунду на GPU;
  • шифрування паролів замість хешування;
  • власна «сіль» чи комбінації алгоритмів - password_hash і Laravel уже додають сіль автоматично;
  • знижена вартість у продакшені через BCRYPT_ROUNDS з тестового .env: у тестах низька вартість доречна для швидкості (phpunit.xml задає BCRYPT_ROUNDS=4), але не на сервері.

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