Питання на співбесіді: Безпека
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
11 питань
CSRF (Cross-Site Request Forgery) - це атака, коли сторонній сайт змушує браузер автентифікованого користувача надіслати небажаний запит на ваш застосунок, використовуючи його активну сесію (наприклад, прихована форма, що переказує гроші).
Як Laravel захищає: для кожної активної сесії генерується унікальний CSRF-токен. Middleware ValidateCsrfToken перевіряє цей токен для всіх «небезпечних» методів - POST, PUT, PATCH, DELETE. Запити без валідного токена відхиляються з кодом 419.
У формах додають директиву @csrf, яка вставляє прихований інпут із токеном:
<form method="POST" action="/profile">
@csrf
<input name="email" type="email">
<button>Зберегти</button>
</form>
Для AJAX токен передають у заголовку X-CSRF-TOKEN (зазвичай із <meta name="csrf-token">):
fetch('/profile', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': token },
});
GET-запити токена не потребують (вони мають бути безпечними й не змінювати стан). Для stateless API на токенах (Sanctum) CSRF не застосовується.
Mass Assignment - це присвоєння групи атрибутів моделі з масиву (наприклад, Model::create($request->all())). Вразливість виникає, коли користувач підкидає неочікувані поля (скажімо, is_admin).
Захист - білий або чорний список у моделі:
protected $fillable = ['title', 'body']; // дозволено лише ці
// або
protected $guarded = ['id', 'is_admin']; // заборонено ці
Найкраща практика: не передавати $request->all(), а валідувати й передавати $request->validated().
Eloquent і конструктор запитів передають значення окремо від SQL - прив'язками параметрів (prepared statements). База отримує WHERE email = ? і значення поруч, тож введення користувача ніколи не стає частиною команди.
User::where('email', $request->email)->first(); // безпечно
Де захист перестає працювати:
- Сирі методи з підставленим значенням:
User::whereRaw("email = '{$request->email}'")->first(); // ін'єкція
User::whereRaw('email = ?', [$request->email])->first(); // безпечно
Те саме для selectRaw, orderByRaw, DB::statement(), DB::raw().
- Назви колонок з введення. PDO не прив'язує назви колонок:
Post::orderBy($request->input('sort'))->get(); // небезпечно
Колонку беруть зі списку дозволених:
$sort = in_array($request->sort, ['title', 'created_at'], true) ? $request->sort : 'created_at';
- Ключі масиву в
update()з$request->all()- це вже масове присвоєння, від якого захищають$fillableіvalidated().
Правило: усе, що прийшло від користувача, - тільки значеннями через прив'язки, ніколи не частиною SQL.
Signed URL - посилання з криптографічним підписом у query-рядку. Якщо хтось змінить URL, підпис стане недійсним - підробка неможлива без APP_KEY.
// згенерувати
URL::signedRoute('unsubscribe', ['user' => $id]);
URL::temporarySignedRoute('download', now()->addMinutes(30), ['file' => $id]);
// перевірити (middleware або вручну)
Route::get('/download/{file}', ...)->middleware('signed');
Застосування: посилання-відписки в листах, тимчасові посилання на скачування, підтвердження email - там, де потрібен захищений доступ без автентифікації. temporarySignedRoute додає термін дії.
APP_KEY - ключ, яким Laravel шифрує й підписує дані.
Що від нього залежить:
- шифровані cookie, зокрема сесійна;
Crypt::encrypt()і кастencryptedу моделях;- підписані URL (
URL::signedRoute()); - зашифровані завдання черги (
ShouldBeEncrypted).
Паролі від нього не залежать - вони хешуються bcrypt чи argon2, а не шифруються.
Якщо ключ змінити: усі сесії завершаться (cookie не розшифруються), підписані посилання перестануть працювати, а зашифровані колонки в базі стане неможливо прочитати.
Як змінити без цього: старий ключ переносять у APP_PREVIOUS_KEYS. Laravel шифрує новим, а розшифровує по черзі новим і попередніми.
Якщо ключ витік - зловмисник може підробляти cookie й підписані URL, розшифрувати дані з бази. Тому:
- згенерувати новий ключ, старий - тимчасово в
APP_PREVIOUS_KEYS; - перешифрувати дані з кастом
encryptedновим ключем; - прибрати старий ключ з
APP_PREVIOUS_KEYS.
Ключ не комітять у репозиторій і не логують; для .env у репозиторії є php artisan env:encrypt.
Завантаження файлів - одна з найчастіших дір: файл може виявитися скриптом, перезаписати чужий файл чи покласти сервер розміром.
Валідація:
$request->validate([
'avatar' => ['required', File::image()->max('2mb')->dimensions(Rule::dimensions()->maxWidth(4000))],
'contract' => ['required', 'file', 'mimes:pdf', 'max:10240'],
]);
mimesвизначає тип за вмістом, а не за назвою;extensionsперевіряє розширення, яке дав користувач - корисно разом зmimes;max- у кілобайтах; ліміт розміру тіла запиту ще й на вебсервері.
Збереження:
- ім'я -
hashName()(випадкове) і розширення -extension()(за вмістом).getClientOriginalName()можна підробити, зокрема з../у шляху; - приватні документи - на приватний диск, віддавати через контролер з перевіркою прав чи
temporaryUrl(); - публічний диск ніколи не має виконувати PHP: файли віддає вебсервер як статику.
Обробка:
- зображення перекодувати (Laravel 13:
$request->image('avatar')->toWebp()) - нове кодування зазвичай відкидає вбудований у файл сторонній вміст і метадані на кшталт геолокації (результат варто перевірити для свого драйвера); - великі файли обробляти в черзі.
Оригінальне ім'я файлу, якщо воно потрібне людям, зберігають окремо в базі й екранують при виводі.
Шифрування в Laravel - симетричне, з ключем APP_KEY, алгоритм за замовчуванням AES-256-CBC з підписом HMAC (чи AES-GCM з вбудованою автентифікацією). Зашифроване значення не можна ні прочитати, ні непомітно змінити без ключа.
use Illuminate\Support\Facades\Crypt;
$token = Crypt::encryptString($apiToken);
$apiToken = Crypt::decryptString($token);
encryptString чи encrypt:
encryptString()/decryptString()- шифрує рядок як є;encrypt()/decrypt()(і хелпериencrypt(),decrypt()) спершу серіалізують значення черезserialize()- можна шифрувати масиви й об'єкти, але при розшифруванні викликаєтьсяunserialize(). Для рядків кращеencryptString.
Результат - base64 від JSON з полями iv, value, mac (чи tag для GCM). Він довший за вихідний рядок: для колонки бази потрібен text, а не короткий varchar.
Помилки розшифрування:
try {
$value = Crypt::decryptString($payload);
} catch (DecryptException $e) {
// значення пошкоджено, підроблено чи зашифровано іншим ключем
}
Ротація ключа. Якщо просто замінити APP_KEY, усе зашифроване старим ключем стане нечитабельним: cookie, сесії, поля з cast encrypted, збережені токени. Для плавної заміни:
APP_KEY="base64:NEW..."
APP_PREVIOUS_KEYS="base64:OLD..."
- шифрування - завжди новим ключем;
- розшифрування - спершу новим, потім по черзі старими з
APP_PREVIOUS_KEYS; - користувачі не розлогінюються, дані читаються.
Але старі ключі треба колись прибрати: дані в базі, зашифровані старим ключем, лишаються такими, доки їх не перезаписати. Після ротації - команда чи задача, що читає й зберігає ці записи заново (вони перешифровуються новим ключем), і лише потім видалення старого ключа з APP_PREVIOUS_KEYS.
Що не шифрувати через Crypt:
- паролі - їх хешують (
Hash::make), а не шифрують: розшифрувати пароль неможливо має бути навіть для вас; - поля, за якими шукаєте: зашифроване значення щоразу різне (випадковий IV), тому
where('email', ...)не спрацює. Для пошуку - окрема колонка з хешем (HMAC) значення.
Ключ - єдиний секрет усієї схеми. Витік APP_KEY разом з дампом бази - це витік усіх зашифрованих даних.
- Паролі - лише одностороннє хешування (Bcrypt/Argon2):
Hash::make()/Hash::check(). Ніколи не шифрування й не власні алгоритми. - PII (двостороннє) -
Crypt::encryptString()або кастencryptedна атрибуті моделі:protected $casts = ['ssn' => 'encrypted']; - Ключі та секрети - у
.env/ секретних сховищах (AWS Secrets Manager, Vault), не в git. РотаціяAPP_KEYпотребує перешифрування. - Транзит - лише HTTPS/TLS.
- Логи - маскувати PII; уникати
dd()у проді. - Доступ - принцип найменших привілеїв, audit log (наприклад,
spatie/laravel-activitylog).
CSP - HTTP-заголовок, що визначає, з яких джерел дозволено завантажувати ресурси (скрипти, стилі, зображення). Це потужний захист від XSS: навіть якщо зловмисник впровадить <script>, браузер не виконає його, якщо джерело не дозволене.
Content-Security-Policy: default-src 'self';
script-src 'self' https://cdn.example.com;
img-src 'self' data:;
У Laravel заголовок додають через middleware (вручну або пакетом на кшталт spatie/laravel-csp).
Практики:
- Уникати
'unsafe-inline'- використовувати nonce для інлайн-скриптів. - Спершу режим report-only (
Content-Security-Policy-Report-Only) зі збором звітів, щоб не зламати сайт.
За балансувальником чи CDN застосунок бачить з'єднання від проксі, а справжні дані клієнта приходять у заголовках X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host.
Без налаштування:
$request->ip()повертає IP балансувальника - обмеження частоти й журнали «бачать» одного клієнта;$request->secure()-false, і Laravel генерує посилання зhttp://.
Довірені проксі:
->withMiddleware(function (Middleware $middleware): void {
$middleware->trustProxies(at: ['10.0.0.0/8']);
})
Laravel читає X-Forwarded-* лише від цих адрес. at: '*' безпечний, тільки коли застосунок недоступний напряму, - інакше будь-хто надішле X-Forwarded-For: 1.2.3.4 і обійде обмеження частоти чи журнали.
Довірені хости: абсолютні URL будуються з заголовка Host. Якщо вебсервер пропускає будь-який хост, запит на скидання пароля з підробленим Host дасть лист з посиланням на домен зловмисника - разом з токеном.
$middleware->trustHosts(at: ['^example\.com$'], subdomains: true);
Краще, коли вебсервер узагалі не приймає невідомі хости.
За Cloudflare реальний IP приходить ще й у CF-Connecting-IP; довіряти йому можна лише для запитів з діапазонів Cloudflare.
Перебір (/invoices/1, /invoices/2...) і скрапінг шукають дані, які віддаються без достатніх перевірок, або просто вивантажують усе відкрите.
Перша лінія - авторизація. Кожен запис перевіряється політикою, а запити списків починаються від власника. Якщо цього немає, решта заходів лише гальмує витік.
Не розкривати існування: для чужих ресурсів - 404, а не 403 (Response::denyAsNotFound()), щоб перебір не відрізняв «чуже» від «немає».
Обмеження частоти з розумом:
RateLimiter::for('lookups', function (Request $request) {
return Limit::perMinute(10)
->by($request->user()?->id ?: $request->ip())
->after(fn (Response $response) => $response->status() === 404);
});
after() рахує лише 404 - звичайні користувачі не впираються в ліміт, а перебір швидко зупиняється.
Непередбачувані ідентифікатори (UUID, ULID) у публічних URL ускладнюють перебір, але не замінюють авторизацію.
Проти масового збору відкритих даних:
- пагінація з обмеженням розміру сторінки, без «віддати все»;
- ліміти для гостей суворіші, ніж для автентифікованих;
- захист на рівні CDN (WAF, challenge) для явних ботів - він дешевший за обробку в PHP;
- моніторинг: різкий ріст 404 з одного джерела - сигнал.
Докладніше в документації: Обмеження частоти на основі відповіді