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 - обходять екранування.
Подвійне кодування: {{ }} за замовчуванням не кодує вже закодовані сутності двічі (& лишиться &); Blade::withoutDoubleEncoding() керує цим.
Правило для рев'ю коду: кожен {!! !!} має бути обґрунтований - звідки береться HTML і хто гарантує, що він безпечний.
Масове призначення - заповнення моделі масивом: 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 на будь-яку помилку показує детальну сторінку: повідомлення винятку, стек викликів з файлами й рядками, фрагменти коду, параметри запиту. Для 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/, а не на корінь проєкту.
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, а старі браузери й піддомени - окремі випадки.
$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: робота з перевіреними даними