Питання на співбесіді: Безпека 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 - обходять екранування.
Подвійне кодування: {{ }} за замовчуванням не кодує вже закодовані сутності двічі (& лишиться &); Blade::withoutDoubleEncoding() керує цим.
Правило для рев'ю коду: кожен {!! !!} має бути обґрунтований - звідки береться HTML і хто гарантує, що він безпечний.
Масове призначення - заповнення моделі масивом: 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 на будь-яку помилку показує детальну сторінку: повідомлення винятку, стек викликів з файлами й рядками, фрагменти коду, параметри запиту. Для 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/, а не на корінь проєкту.
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, а старі браузери й піддомени - окремі випадки.
$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 шифрує лише новим ключем, а розшифровує - пробуючи поточний і попередні. Користувачі не розлогінюються, старі посилання діють.
Повна ротація (наприклад, після витоку ключа):
- новий ключ у
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
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, що вивели змінні оточення.
Реагування на витік:
- новий
APP_KEY; старий - не вAPP_PREVIOUS_KEYS(інакше підроблені дані й далі розшифровуватимуться), тож свідомо прийняти, що всі сесії й посилання стануть недійсними; - перешифрувати дані в базі (потрібен старий ключ для читання - тимчасово в окремому скрипті);
- завершити всі сесії, відкликати токени;
- розслідувати, як ключ витік, і перевірити логи на ознаки використання;
- ротувати й інші секрети з того самого
.env- найімовірніше, витік не обмежився одним ключем.
Профілактика: секрети поза репозиторієм і образами (змінні оточення, менеджер секретів, env:encrypt), корінь вебсервера на public/, сканування репозиторіїв на секрети.
Довірені проксі. Якщо перед застосунком стоїть балансувальник чи 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):
- зловмисник надсилає запит на скидання пароля для email жертви з підробленим заголовком
Host: evil.example; - якщо URL у листі будується з хоста поточного запиту, жертва отримує справжній лист від вашого сервісу з посиланням
https://evil.example/reset-password/{token}; - жертва переходить - токен потрапляє до зловмисника, і він скидає пароль.
Те саме з будь-якими абсолютними посиланнями, згенерованими з запиту: підтвердження 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 - відповідь має бути помилкою, а не нормальною сторінкою.
Налаштування в 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 хешує паролі через 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), але не на сервері.