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

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

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

31 питань

Завантаження файлів - один із найризикованіших механізмів: нападник контролює і вміст, і ім'я файлу.

Основні загрози:

  • Виконання коду: shell.php (або image.php.jpg при неправильно налаштованому сервері) у публічному каталозі - і нападник виконує довільний код.
  • Збережений XSS: SVG чи HTML-файл зі скриптом, відкритий з вашого домену.
  • Перезапис файлів через ім'я на кшталт ../../config/app.php.
  • Відмова в обслуговуванні: величезні файли, «zip-бомби», зображення з гігантською роздільною здатністю.

Захист:

  • Не довіряти імені й MIME-типу від клієнта. Генерувати власне ім'я (UUID) і визначати тип за вмістом. Розширення - з білого списку.
  • Зберігати поза веб-коренем або в об'єктному сховищі (S3), а віддавати через контролер чи підписаний URL.
  • Окремий домен для користувацьких файлів (usercontent.example) - тоді навіть шкідливий HTML не має доступу до cookie основного сайту.
  • Заборонити виконання в каталозі завантажень на рівні веб-сервера.
  • Обмежити розмір на всіх рівнях: веб-сервер, PHP (upload_max_filesize, post_max_size), валідація застосунку.
  • Перекодувати зображення (зменшити, пересохранити через GD/Imagick) - це прибирає вбудовані дані й метадані (EXIF з геолокацією).
  • Content-Disposition: attachment і X-Content-Type-Options: nosniff для файлів, які не мають відкриватися в браузері.
  • Антивірусна перевірка для файлів, які завантажуватимуть інші користувачі.
$request->validate([
    'avatar' => ['required', 'image', 'mimes:jpg,png,webp', 'max:2048', 'dimensions:max_width=4000,max_height=4000'],
]);

$path = $request->file('avatar')->store('avatars', 's3');   // ім'я генерує Laravel

SVG - окремий випадок: це XML зі скриптами. Його або забороняють, або санітизують, або віддають лише як attachment.

Докладніше в документації: OWASP: завантаження файлів

unserialize() у PHP відтворює не лише дані, а й об'єкти довільних класів з довільними значеннями властивостей. При цьому автоматично викликаються магічні методи: __wakeup(), __unserialize(), а згодом __destruct().

Якщо нападник контролює рядок для unserialize(), він може створити об'єкти класів, які вже є в застосунку чи його залежностях, з потрібними йому властивостями. Ланцюжок викликів магічних методів («POP chain») призводить до запису файлів, SQL-запитів чи виконання коду. Готові ланцюжки для популярних фреймворків і бібліотек відомі й зібрані в інструментах на кшталт PHPGGC.

// Вразливо
$cart = unserialize($_COOKIE['cart']);

Захист:

  • Не десеріалізувати дані від користувача. Для обміну даними - json_decode(): він створює лише масиви й stdClass, без виклику коду класів.
  • Якщо unserialize() неминучий - allowed_classes:
$data = unserialize($payload, ['allowed_classes' => false]);           // лише скаляри й масиви
$data = unserialize($payload, ['allowed_classes' => [Money::class]]);  // білий список
  • Підписувати серіалізовані дані, що проходять через клієнта (HMAC), і перевіряти підпис до десеріалізації.

Де це трапляється в Laravel-застосунках:

  • Laravel шифрує cookie й підписує завдання черги, тож напряму від користувача серіалізовані дані туди не потрапляють. Але витік APP_KEY дозволяє підробити зашифровані дані - і раніше це давало виконання коду через десеріалізацію. Тому витік ключа - критичний інцидент.
  • Кеш і сесії в Redis/Memcached серіалізуються: доступ нападника до кешу - теж шлях до цієї атаки. Laravel дозволяє обмежити класи, які можна десеріалізувати з кешу (serializable_classes у config/cache.php).
  • Phar-архіви: у старих версіях PHP файлові функції з шляхом phar:// десеріалізували метадані архіву. PHP 8.0 це прибрав.

Аналогічні проблеми мають pickle у Python, Java-серіалізація, YAML.load у Ruby.

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

ReDoS (Regular expression Denial of Service) - регулярний вираз, який на спеціально підібраному рядку працює експоненційно довго. Один запит займає процесор на секунди чи хвилини; кілька - і сервер не відповідає.

Причина - катастрофічний бектрекінг. Більшість рушіїв регулярних виразів (PCRE у PHP, рушій JavaScript) при невдачі повертаються й пробують інші варіанти розбиття рядка. Вкладені квантифікатори дають експоненційну кількість варіантів:

^(a+)+$         на рядку "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!"
^(\w+\s?)*$     на довгому рядку слів із символом у кінці, що не підходить
^(a|aa)+$

Небезпечні ознаки: вкладений квантифікатор ((x+)+, (x*)*), перекриваючі альтернативи під квантифікатором ((a|a)*, (\w|\d)+), і рядок, який майже підходить, але ламається в кінці.

Де шукати в застосунку:

  • власні правила валідації (regex: у Laravel), перевірки email, URL, телефонів;
  • регулярні вирази з даних користувача (пошук за шаблоном) - найгірший варіант: зловмисник сам пише вираз;
  • розбір логів, парсери вмісту, підсвітка синтаксису.

Як поводиться PHP: PCRE має ліміти pcre.backtrack_limit і JIT-стек. При перевищенні preg_match повертає false (не 0!), а preg_last_error() - PREG_BACKTRACK_LIMIT_ERROR. Це рятує від нескінченного зависання, але:

  • до ліміту процесор усе одно працює;
  • код, що перевіряє if (! preg_match(...)), сприйме false як «не підходить» - і в деяких випадках це обхід валідації.

Захист:

  • переписувати вирази без неоднозначності: ^\w+(\s\w+)*$ замість ^(\w+\s?)*$, атомарні групи (?>...) і присвійні квантифікатори (a++), які забороняють повернення;
  • обмежувати довжину вводу перед регулярним виразом - найпростіший і дуже ефективний захист;
  • не приймати регулярні вирази від користувачів; якщо потрібно - рушій з лінійним часом (RE2) чи обмеження часу виконання;
  • перевіряти preg_last_error() і трактувати false як помилку;
  • готові валідатори (filter_var для email/URL, бібліотека libphonenumber) замість саморобних виразів;
  • статичні аналізатори (наприклад, правила Semgrep, safe-regex для JS) знаходять небезпечні шаблони.

Node.js особливо вразливий: однопотоковий цикл подій - один повільний регулярний вираз блокує обробку всіх запитів.

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

Оператор == у PHP перед порівнянням приводить типи. Правила складні, і на них будувалися обходи автентифікації й перевірок.

«Магічні» хеші. Два числові рядки порівнюються як числа. Рядок виду "0e" + цифри - число в експоненційному записі, тобто нуль:

"0e123456" == "0e987654";   // true: обидва дорівнюють 0
md5('240610708') == md5('QNKCDZO');   // true - обидва хеші починаються з 0e і далі лише цифри

Перевірка if ($providedHash == $storedHash) пропускає інший рядок з таким самим «нульовим» хешем. Так обходили перевірку токенів скидання пароля й підписів.

Інші пастки (поведінка PHP 8):

"1" == "01";        // true  - числові рядки
"10" == "1e1";      // true
100 == "1e2";       // true
null == false;      // true
[] == false;        // true
"0" == false;       // true

PHP 8 виправив найгірше: "abc" == 0 тепер false (у PHP 7 було true), а in_array('abc', [0]) - false. Але порівняння числових рядків і null/false лишилися.

Де це небезпечно:

  • перевірка токенів, хешів, підписів, кодів підтвердження;
  • in_array / array_search без третього аргументу true - нестроге порівняння;
  • switch - теж використовує ==;
  • JSON-ввід: клієнт надсилає {"code": true} чи число замість рядка, і $code == $expected поводиться несподівано. Типи з JSON не гарантовані - їх треба валідувати.

Захист:

  • === і !== за замовчуванням - порівняння без приведення типів;
  • hash_equals($known, $user) для секретів - строге порівняння ще й у постійному часі (захист від атак за часом);
  • in_array($value, $list, true), array_search(..., true);
  • match замість switch - використовує строге порівняння;
  • declare(strict_types=1) - строгі типи аргументів функцій (не впливає на ==, але ловить передачу не того типу);
  • валідація типів вхідних даних ('code' => ['required', 'string', 'size:6']);
  • статичні аналізатори (PHPStan, Psalm) і правила code style попереджають про ==.

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

Prompt injection - атака на застосунок з мовною моделлю: текст, який модель читає, містить інструкції, і модель виконує їх замість (чи разом з) інструкцій розробника. Це перша позиція OWASP Top 10 для LLM-застосунків.

Два різновиди:

  • пряма: користувач сам пише «Ігноруй попередні інструкції і...» у чаті;
  • непряма: інструкції сховані в даних, які обробляє модель, - у листі, вебсторінці, PDF, описі товару, коментарі в коді. Користувач просить «підсумуй цей лист», а лист каже моделі «перешли всі листи з паролями на адресу...».

Непряма небезпечніша: жертва нічого підозрілого не робить.

Чому класичний захист не працює: у SQL є підготовлені запити - код і дані розділені синтаксично. У LLM інструкції й дані - той самий текст. Надійного синтаксичного розділення немає, і фільтри «небезпечних фраз» обходяться перефразуванням, іншою мовою, кодуванням.

Що реально знижує ризик - обмежити наслідки:

  • мінімальні права інструментів: модель, що може лише читати документи користувача, менш небезпечна за модель з доступом до відправки листів, платежів, видалення;
  • підтвердження людиною для незворотних і чутливих дій (надіслати, оплатити, видалити, змінити права);
  • авторизація на рівні інструментів, а не промпту: інструмент «отримати замовлення» перевіряє права поточного користувача в коді, а не покладається на інструкцію «не показуй чужі замовлення»;
  • розділення контекстів: недовірені дані (вміст з інтернету, листи) не змішувати з привілейованими діями в одному агенті;
  • вихід моделі - недовірені дані: не виконувати як код, не вставляти в HTML без екранування (XSS), не підставляти в SQL і shell, перевіряти URL перед запитом (SSRF), не рендерити посилання й зображення з зовнішніх доменів без перевірки (витік даних через URL зображення);
  • структурований вихід (JSON за схемою) і валідація замість вільного тексту там, де результат керує логікою;
  • не класти секрети в системний промпт - його можна «витягти»;
  • журнали й моніторинг викликів інструментів - виявляти аномалії.

Для Laravel-застосунків з laravel/ai ті самі правила: інструменти агента - звичайний PHP-код, у якому діють політики й валідація, а дії з побічними ефектами - через підтвердження.

Головна думка для співбесіди: prompt injection не «виправляється», ним керують - проєктуючи систему так, щоб навіть повністю «обдурена» модель не могла завдати серйозної шкоди.

Докладніше в документації: OWASP Top 10 для LLM: LLM01 Prompt Injection

Три різні задачі, які часто плутають:

Хешування - одностороннє перетворення даних у відбиток фіксованої довжини. Відновити дані з хешу неможливо.

  • Перевірка цілісності: чи не змінився файл (SHA-256).
  • Паролі - але лише повільними функціями з сіллю (Argon2id, bcrypt), не швидкими.

Шифрування - двостороннє: з ключем дані можна розшифрувати.

  • Симетричне (AES-GCM, ChaCha20-Poly1305): один ключ шифрує й розшифровує. Швидке; для даних у базі, файлів, cookie.
  • Асиметричне (RSA, X25519): публічний ключ шифрує, лише приватний розшифровує. Для обміну ключами й шифрування для конкретного отримувача.

Використовують автентифіковане шифрування (AEAD): воно не лише приховує дані, а й виявляє їхню зміну. Шифрування без перевірки цілісності (AES-CBC без MAC) вразливе до підміни шифротексту.

Підпис / MAC - доводить, що дані не змінено і створено тим, хто має ключ. Дані при цьому лишаються відкритими.

  • HMAC (симетричний): спільний секрет - підписані URL, підписи вебхуків, JWT з HS256.
  • Цифровий підпис (Ed25519, ECDSA, RSA): приватний ключ підписує, будь-хто з публічним - перевіряє. JWT з RS256/EdDSA, підписи релізів, TLS-сертифікати.

У Laravel:

Crypt::encryptString($iban);       // AES-256-CBC + HMAC (або GCM): шифрування з перевіркою цілісності
Hash::make($password);             // повільний хеш пароля
URL::signedRoute('unsubscribe', ['user' => $id]);   // HMAC-підпис

Головні правила: не винаходити власну криптографію, використовувати перевірені бібліотеки (libsodium, вбудовані засоби фреймворку), зберігати ключі окремо від даних і мати план ротації ключів.

Докладніше в документації: OWASP: криптографічне зберігання

Застосунок - це ваш код плюс сотні пакетів Composer і npm, кожен з яких може мати власні залежності. Атака на ланцюжок постачання - компрометація не вашого коду, а чогось, від чого він залежить: пакета, інструмента збирання, CI.

Як це відбувається:

  • Викрадений обліковий запис мейнтейнера - і в популярний пакет потрапляє шкідлива версія.
  • Typosquatting - пакет з назвою, схожою на популярну.
  • Dependency confusion - публічний пакет з назвою вашого внутрішнього, який менеджер пакетів вибирає замість приватного.
  • Шкідливі скрипти встановлення - postinstall у npm виконується одразу під час npm install.
  • Компрометація CI - секрети з пайплайна, підміна артефактів збирання.

Захист:

  • Lock-файли в репозиторії (composer.lock, package-lock.json) і встановлення лише з них у CI й на проді (composer install, npm ci).
  • Аудит вразливостей: composer audit, npm audit, Dependabot чи Renovate з автоматичними pull request на оновлення.
  • Оновлення - регулярно, але не наосліп: читати changelog, дати новій версії кілька днів «відлежатися», переглядати diff у критичних пакетах.
  • Менше залежностей: кожен пакет - ще одна точка довіри. Для дрібної функції часто простіше написати кілька рядків, ніж додати пакет.
  • Обмеження скриптів: npm ci --ignore-scripts, де можливо; у Composer плагіни потребують явного дозволу (allow-plugins).
  • Захист CI: мінімальні права токенів, секрети лише для гілок, яким довіряєте, закріплення сторонніх GitHub Actions за хешем коміту, а не тегом.
  • Перевірка походження: підписи й attestations пакетів (npm provenance, Sigstore), SBOM для розуміння, що саме в застосунку.

Висновок: залежність - це чужий код, який ви запускаєте з повними правами застосунку. Ставитися до неї варто відповідно.

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

Атака за часом - зловмисник вимірює, скільки часу сервер обробляє запит, і з різниці робить висновки про секрет.

Класичний приклад - порівняння рядків:

if ($providedToken === $storedToken) { ... }

Звичайне порівняння зупиняється на першому символі, що не збігся. Токен, у якого правильні перші 10 символів, перевіряється трохи довше, ніж токен з неправильним першим символом. Вимірюючи час тисяч запитів і усереднюючи шум, теоретично можна підбирати токен посимвольно: замість 62^32 варіантів - 62 × 32 спроби.

hash_equals($known, $user) порівнює рядки за постійний час - незалежно від того, де саме відрізняються символи:

if (hash_equals($storedSignature, $providedSignature)) { ... }

Порядок аргументів важливий: перший - відоме (секретне) значення, другий - від користувача.

Де потрібне порівняння в постійному часі:

  • підписи вебхуків і запитів (HMAC);
  • API-ключі й токени, якщо порівнюються як рядки;
  • коди підтвердження, CSRF-токени;
  • підписані URL.

Laravel використовує hash_equals для перевірки CSRF-токенів, підписаних маршрутів, токенів Sanctum.

Інші джерела витоку через час:

  • перевірка пароля лише для існуючих користувачів: для неіснуючого email сервер відповідає миттєво, для існуючого - після bcrypt (сотні мілісекунд). Це дає перелік облікових записів. Захист - виконувати хешування завжди;
  • різна робота залежно від результату: відправка листа лише якщо обліковий запис існує, запит до бази лише для певних випадків;
  • пошук у базі за секретом: WHERE token = ? - час пошуку за індексом теоретично теж може залежати від значення. Тому надійніше шукати за ідентифікатором, а секрет порівнювати окремо через hash_equals (так зроблено в Sanctum: токен має вигляд id|секрет).

Наскільки це реально в інтернеті: мережевий шум у мілісекунди набагато більший за наносекундні різниці порівняння рядків, тож атака на сирі рядкові порівняння через інтернет складна. Але з тисячами вимірювань і статистикою вона можлива, а в межах одного хмарного регіону шум значно менший. Порівняння в постійному часі коштує нічого - немає причин ризикувати.

Не лише рядки: криптографічні реалізації мають бути «constant-time» загалом - тому використовують перевірені бібліотеки (sodium_*, openssl_*), а не власні реалізації алгоритмів.

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

«Увійти через Google/GitHub» здається простим, бо бібліотека (у Laravel - Socialite) бере на себе протокол. Але кілька помилок у логіці застосунку роблять його небезпечним.

1. Об'єднання облікових записів за email без перевірки.

$user = User::firstOrCreate(['email' => $socialUser->getEmail()], [...]);
Auth::login($user);

Якщо провайдер дозволяє вказати неперевірений email, зловмисник створює там обліковий запис з email жертви - і входить у її обліковий запис на вашому сайті. Правила:

  • зв'язувати лише з підтвердженим у провайдера email (наприклад, email_verified в OpenID Connect);
  • зберігати ідентифікатор провайдера (provider + provider_id), а не лише email, і шукати насамперед за ним;
  • прив'язку соцмережі до існуючого облікового запису робити з облікового запису (користувач уже увійшов паролем) чи з підтвердженням поштою.

2. Відсутність перевірки state. Параметр state пов'язує відповідь провайдера із сесією користувача, що почав вхід. Без нього можлива CSRF-атака на вхід: жертву непомітно входять в обліковий запис зловмисника (і вона, наприклад, зберігає туди свої дані). Socialite перевіряє state за замовчуванням; stateless() вимикає перевірку - лише для API з PKCE.

3. Відкрите перенаправлення. Параметр «куди повернутися після входу» (?redirect=) без перевірки - фішинг на вашому домені. Дозволений лише відносний шлях свого сайту.

4. Неправильні redirect URI в налаштуваннях провайдера. Шаблони (https://*.example.com/*) чи зайві адреси дозволяють перехопити код авторизації. Точні адреси, лише HTTPS.

5. Довіра до даних профілю. Ім'я, аватар, email від провайдера - звичайні дані користувача: їх треба валідувати й екранувати.

6. Втрата доступу при видаленні облікового запису провайдера. Користувач без пароля, що втратив Google-акаунт, втрачає доступ - потрібні альтернативні способи входу.

7. Зайві права (scopes). Просити лише потрібне (openid email profile), а не доступ до пошти чи диска «на майбутнє».

8. Токени провайдера. Якщо застосунку справді потрібні токени доступу до API провайдера - зберігати їх зашифрованими (encrypted cast) і оновлювати refresh-токенами.

Для співбесіди: найважливіше - пункт 1. Захоплення облікових записів через неперевірений email - найпоширеніша реальна вразливість соціального входу.

Докладніше в документації: OWASP: шпаргалка з OAuth 2.0

Для безпеки потрібна не просто «випадковість», а непередбачуваність: знаючи попередні значення, неможливо вгадати наступні.

Некриптографічні генератори - mt_rand(), rand(), uniqid(), lcg_value(), Math.random() у JavaScript:

  • побудовані для швидкості й рівномірного розподілу, а не для безпеки;
  • внутрішній стан відновлюється з кількох виходів - для Mersenne Twister (mt_rand) відомі інструменти, що за кількома значеннями обчислюють зерно й передбачають усі наступні;
  • uniqid() - це просто поточний час у мікросекундах: передбачуваний.

Криптографічно стійкі генератори (CSPRNG) - з джерела ентропії операційної системи:

random_bytes(32);                 // 32 випадкові байти
bin2hex(random_bytes(32));        // 64 hex-символи
random_int(100000, 999999);       // число в діапазоні - для кодів підтвердження
Str::random(40);                  // Laravel: на основі random_bytes
Str::password(16);                // Laravel: пароль з різними класами символів

PHP 8.2 також має ООП-API Random\Randomizer з рушієм Random\Engine\Secure (за замовчуванням).

Де потрібен CSPRNG: токени сесій і API, токени скидання пароля й підтвердження email, коди 2FA й одноразові паролі, CSRF-токени, ключі шифрування, солі (зазвичай генерує password_hash), ідентифікатори, що служать «секретом» (неперебірні посилання на документи).

Розмір має значення: 128 біт ентропії (16 байтів) - мінімум для токенів, що мають бути неперебірними; 256 біт - з запасом. Шестизначний код - лише ~20 біт, тож його захищає не випадковість, а обмеження спроб і короткий термін дії.

Пастки:

  • md5(time()), sha1(uniqid()), md5(rand()) - хешування не додає ентропії: передбачуване значення лишається передбачуваним;
  • зменшення діапазону через % (random_bytes + % 10) дає нерівномірний розподіл - для чисел використовувати random_int;
  • UUID як секрет: UUIDv4 має 122 випадкові біти і для більшості цілей годиться, якщо генерується CSPRNG. Але UUIDv7 (Laravel HasUuids, Str::uuid7()) містить мітку часу і менше випадкових бітів - як ідентифікатор він добрий, а як секретний токен - ні;
  • mt_srand() з фіксованим зерном у коді безпеки - повна передбачуваність.

У JavaScript для безпеки - crypto.getRandomValues() і crypto.randomUUID(), а не Math.random().

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

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: хешування

DOM XSS виникає повністю в браузері: JavaScript бере дані з контрольованого нападником джерела (location.hash, location.search, postMessage, відповідь API) і передає в небезпечний приймач (sink):

element.innerHTML = location.hash.slice(1);      // HTML-приймач
script.src = params.get('widget');               // URL скрипта
setTimeout(userInput);                            // рядок як код
document.write(...);  eval(...);  new Function(...);

Сервер такої вразливості не бачить: шкідливе значення взагалі може не доходити до нього (фрагмент #... не надсилається на сервер). Знайти всі такі місця у великому застосунку й бібліотеках - важко.

Trusted Types змінюють правила гри: з увімкненою політикою небезпечні приймачі відмовляються приймати звичайні рядки. Їм потрібні спеціальні типізовані об'єкти (TrustedHTML, TrustedScript, TrustedScriptURL), які можна створити лише через зареєстровані політики.

Увімкнення - через CSP:

Content-Security-Policy: require-trusted-types-for 'script'; trusted-types app-sanitizer

Політика - єдине місце, де рядок стає «довіреним»:

const sanitizer = trustedTypes.createPolicy('app-sanitizer', {
  createHTML: (input) => DOMPurify.sanitize(input),
});

element.innerHTML = sanitizer.createHTML(userHtml);   // дозволено
element.innerHTML = userHtml;                         // TypeError - заблоковано

Що це дає:

  • кількість місць для перевірки зменшується до кількох політик замість сотень присвоєнь innerHTML по всьому коду;
  • порушення помітні: спроба записати рядок у приймач кидає помилку (і може звітувати через CSP);
  • контроль над бібліотеками: директива trusted-types обмежує, які політики можна створювати, - стороння бібліотека не створить «свою» лазівку непомітно.

Впровадження:

  1. Content-Security-Policy-Report-Only: require-trusted-types-for 'script' - зібрати всі місця, де рядки потрапляють у приймачі;
  2. переписати їх на безпечні API (textContent, setAttribute для безпечних атрибутів) або пропустити через політику з санітизацією;
  3. увімкнути блокування.

Обмеження:

  • підтримка браузерами: Trusted Types давно є в Chromium, інші браузери додавали її пізніше - перед покладанням на них як на основний захист варто перевірити актуальну підтримку. Там, де Trusted Types немає, код має лишатися безпечним сам по собі;
  • фреймворки (React, Vue) мають власний захист від XSS у шаблонах; Trusted Types закривають «аварійні виходи» на кшталт dangerouslySetInnerHTML, v-html і прямої роботи з DOM.

Докладніше в документації: MDN: Trusted Types API

Питання рівня Senior з реальних технічних співбесід - 31 питання у 7 темах, розібраних із відповідями. Нижче - теми цього рівня та сусідні рівні, якщо хочете звузити або розширити підготовку.

Інші рівні
Junior 35 Middle 37

Готуєтесь до співбесіди не просто так: зараз на сайті 46 відкритих вакансій рівня Senior. Переглянути вакансії