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

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

Питання з реальних співбесід з відповідями: 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 не застосовується.

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

Mass Assignment - це присвоєння групи атрибутів моделі з масиву (наприклад, Model::create($request->all())). Вразливість виникає, коли користувач підкидає неочікувані поля (скажімо, is_admin).

Захист - білий або чорний список у моделі:

protected $fillable = ['title', 'body']; // дозволено лише ці
// або
protected $guarded = ['id', 'is_admin']; // заборонено ці

Найкраща практика: не передавати $request->all(), а валідувати й передавати $request->validated().

Докладніше в документації: Mass Assignment

Eloquent і конструктор запитів передають значення окремо від SQL - прив'язками параметрів (prepared statements). База отримує WHERE email = ? і значення поруч, тож введення користувача ніколи не стає частиною команди.

User::where('email', $request->email)->first();   // безпечно

Де захист перестає працювати:

  1. Сирі методи з підставленим значенням:
User::whereRaw("email = '{$request->email}'")->first();     // ін'єкція
User::whereRaw('email = ?', [$request->email])->first();    // безпечно

Те саме для selectRaw, orderByRaw, DB::statement(), DB::raw().

  1. Назви колонок з введення. PDO не прив'язує назви колонок:
Post::orderBy($request->input('sort'))->get();   // небезпечно

Колонку беруть зі списку дозволених:

$sort = in_array($request->sort, ['title', 'created_at'], true) ? $request->sort : 'created_at';
  1. Ключі масиву в 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 додає термін дії.

Докладніше в документації: Підписані URL

APP_KEY - ключ, яким Laravel шифрує й підписує дані.

Що від нього залежить:

  • шифровані cookie, зокрема сесійна;
  • Crypt::encrypt() і каст encrypted у моделях;
  • підписані URL (URL::signedRoute());
  • зашифровані завдання черги (ShouldBeEncrypted).

Паролі від нього не залежать - вони хешуються bcrypt чи argon2, а не шифруються.

Якщо ключ змінити: усі сесії завершаться (cookie не розшифруються), підписані посилання перестануть працювати, а зашифровані колонки в базі стане неможливо прочитати.

Як змінити без цього: старий ключ переносять у APP_PREVIOUS_KEYS. Laravel шифрує новим, а розшифровує по черзі новим і попередніми.

Якщо ключ витік - зловмисник може підробляти cookie й підписані URL, розшифрувати дані з бази. Тому:

  1. згенерувати новий ключ, старий - тимчасово в APP_PREVIOUS_KEYS;
  2. перешифрувати дані з кастом encrypted новим ключем;
  3. прибрати старий ключ з 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 з одного джерела - сигнал.

Докладніше в документації: Обмеження частоти на основі відповіді