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

Junior: питання на співбесіді з теми «Безпека Laravel-застосунку»

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

5 питань

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