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

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

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

103 питань

Content Security Policy (CSP) - заголовок відповіді, яким сервер каже браузеру, звідки дозволено завантажувати й виконувати ресурси на цій сторінці: скрипти, стилі, зображення, шрифти, фрейми, запити fetch.

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-r4nd0m'; img-src 'self' https://cdn.example.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'

Головна ціль - зменшити наслідки XSS. Навіть якщо нападник зміг вставити <script> у сторінку, браузер його не виконає: скрипт не має дозволеного джерела чи правильного nonce. CSP - другий рубіж: основний захист від XSS - екранування виводу, а CSP спрацьовує, коли екранування десь пропустили.

Основні директиви:

  • default-src - запасне правило для всіх типів ресурсів;
  • script-src, style-src, img-src, connect-src (куди можна робити fetch/WebSocket), font-src, frame-src;
  • object-src 'none' - заборона застарілих плагінів;
  • base-uri 'self' - захист від підміни <base>, через яку відносні посилання на скрипти ведуть на чужий домен;
  • form-action - куди можна відправляти форми;
  • frame-ancestors - хто може вбудовувати сторінку у фрейм (захист від clickjacking).

Що CSP блокує за замовчуванням, щойно задано script-src:

  • інлайнові скрипти (<script>...</script>, onclick="...", javascript: у посиланнях);
  • eval() і new Function() - без 'unsafe-eval'.

Чого CSP не дає:

  • не захищає від CSRF, SQL-ін'єкцій та логічних помилок;
  • 'unsafe-inline' у script-src майже повністю знецінює захист від XSS - тому інлайнові скрипти дозволяють через nonce чи хеш, а не через 'unsafe-inline'.

У Laravel вбудованого middleware для CSP немає: заголовок ставлять власним middleware або пакетом spatie/laravel-csp, а Vite::useCspNonce() додає nonce до тегів, які генерує @vite.

Докладніше в документації: MDN: Content Security Policy

Clickjacking - нападник вбудовує ваш сайт у невидимий <iframe> на своїй сторінці й розміщує його над власною приманкою: «Натисніть, щоб отримати приз». Користувач думає, що клікає по кнопці нападника, а насправді натискає «Видалити акаунт», «Підтвердити переказ» чи «Надати доступ» на вашому сайті - зі своєю активною сесією.

<!-- сторінка нападника -->
<iframe src="https://bank.example/transfer?to=attacker" style="opacity:0; position:absolute; top:0"></iframe>
<button>Отримати подарунок</button>

Захист - заборонити вбудовувати сторінки у фрейми на чужих сайтах.

1. CSP frame-ancestors - сучасний спосіб:

Content-Security-Policy: frame-ancestors 'none'
Content-Security-Policy: frame-ancestors 'self' https://partner.example
  • 'none' - сторінку не можна вбудувати ніде;
  • 'self' - лише на сторінках того самого походження;
  • можна перелічити довірені домени.

2. X-Frame-Options - старий заголовок, ще корисний для старих браузерів:

X-Frame-Options: DENY
X-Frame-Options: SAMEORIGIN

ALLOW-FROM застарів і сучасними браузерами не підтримується - для вибіркового дозволу лише frame-ancestors. Якщо задано обидва заголовки, браузери з підтримкою CSP використовують frame-ancestors.

Що варто знати:

  • frame-ancestors не працює в тегу <meta> - лише в HTTP-заголовку;
  • захищати потрібно всі сторінки з діями, а не лише головну: найчастіше атакують налаштування облікового запису, підтвердження платежів, сторінки OAuth-згоди;
  • JavaScript-захист на кшталт «якщо top !== self, перейти на верхній рівень» (frame busting) легко обходиться атрибутом sandbox у фреймі нападника - заголовки надійніші;
  • якщо сайт справді має вбудовуватися (віджети, платіжні форми), - вбудовувати лише окремі сторінки з явним переліком дозволених доменів, а не весь застосунок.

Додатковий бар'єр - cookie сесії з SameSite=Lax чи Strict: у фреймі на чужому сайті браузер не надішле таку cookie, і користувач виявиться неавтентифікованим.

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

Браузери історично вміли «вгадувати» тип вмісту (MIME sniffing): якщо сервер віддав файл як text/plain, а всередині схоже на HTML чи JavaScript, браузер міг обробити його як HTML чи скрипт. Це допомагало погано налаштованим серверам, але відкривало вразливість.

Атака: користувач завантажує на ваш сайт файл, нібито зображення чи текст, але з вмістом-скриптом. Якщо потім цей файл підключають як <script src="/uploads/avatar.jpg"> (наприклад, через XSS, що дозволяє лише посилання на свій домен) чи відкривають напряму, браузер може «впізнати» в ньому HTML або JavaScript і виконати.

X-Content-Type-Options: nosniff вимикає вгадування:

X-Content-Type-Options: nosniff
  • скрипти виконуються лише з JavaScript-типом (text/javascript та подібні) - файл з image/jpeg у <script> буде заблоковано;
  • стилі застосовуються лише з text/css;
  • браузер довіряє заголовку Content-Type, а не вмісту.

Що з цього випливає для бекенду:

  • правильний Content-Type для всього, що віддає сервер: JSON - application/json, JavaScript - text/javascript. З nosniff неправильний тип ламає сайт одразу, тому помилки знаходяться швидко;
  • файли користувачів - з типом, визначеним сервером за вмістом, а не за назвою файлу від користувача. Небезпечні типи (HTML, SVG з можливими скриптами) - з Content-Disposition: attachment, щоб браузер завантажував їх, а не відкривав;
  • окремий домен для користувацького контенту (як githubusercontent.com) - найнадійніший захист: навіть якщо файл виконається, він не матиме доступу до cookie й даних основного сайту.

Як додати в Laravel: власний middleware для групи web або налаштування вебсервера (Nginx/Caddy) для всіх відповідей. Заголовок дешевий і майже не має побічних ефектів - його ставлять завжди, разом з Referrer-Policy, frame-ancestors і CSP.

Докладніше в документації: MDN: X-Content-Type-Options

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

Чим це небезпечно: URL часто містить те, що не повинно потрапляти на інші сайти:

  • токени - посилання для скидання пароля (/reset-password?token=...), підтвердження email, запрошення;
  • ідентифікатори й персональні дані - /orders/9921, /patients/42, пошукові запити з іменами;
  • внутрішні адреси адмінок і службових сторінок.

Якщо на сторінці скидання пароля є посилання на зовнішній сайт чи підключено сторонній скрипт аналітики, токен опиниться в їхніх логах.

Referrer-Policy керує, скільки інформації передавати:

Значення Що отримує інший сайт
no-referrer нічого
same-origin повний URL лише для свого походження, іншим - нічого
strict-origin лише походження (https://example.com/), і нічого при переході з HTTPS на HTTP
strict-origin-when-cross-origin повний URL для своїх, лише походження для чужих, нічого при пониженні до HTTP
unsafe-url завжди повний URL - небезпечно
Referrer-Policy: strict-origin-when-cross-origin

strict-origin-when-cross-origin - типове значення сучасних браузерів, якщо політику не задано. Але покладатися на значення за замовчуванням не варто - краще задати явно.

Для чутливих сторінок (скидання пароля, сторінки з токенами в URL) - no-referrer на рівні сторінки чи окремого посилання:

<meta name="referrer" content="no-referrer">
<a href="https://external.example" rel="noreferrer">...</a>

Кращий підхід - не тримати секрети в URL узагалі: токен з посилання одразу обміняти на сесію чи прибрати з адресного рядка після першого використання (history.replaceState чи редирект на чисту адресу).

Аналітика: деякі маркетингові інструменти покладаються на повний Referer. Компроміс - strict-origin-when-cross-origin, а не unsafe-url: для аналізу джерел трафіку зазвичай досить домену.

Докладніше в документації: MDN: Referrer-Policy

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

// вразливо
return redirect($request->query('return_to'));
https://bank.example/login?return_to=https://bank-example.attacker.io/login

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

Чим ще небезпечно:

  • крадіжка токенів в OAuth: якщо redirect_uri чи проміжний редирект відкритий, код авторизації чи токен може піти на сайт нападника;
  • обхід перевірок «дозволених доменів» в інших системах, що довіряють вашому домену;
  • SSRF, якщо сервер сам переходить за такими редиректами.

Як повертати користувача безпечно:

1. Лише відносні шляхи свого сайту:

$target = $request->query('return_to', '/');

$isLocalPath = str_starts_with($target, '/')
    && ! str_starts_with($target, '//')
    && ! str_contains($target, '\\');

return redirect($isLocalPath ? $target : '/');

//attacker.io - це протокол-відносний URL на чужий домен, а деякі браузери трактують /\attacker.io так само. Тому перевірка лише на початковий / недостатня.

2. Дозволений список - якщо потрібні переходи на інші домени (піддомени, партнери): порівнювати розібраний хост (parse_url) з переліком, а не шукати підрядок (str_contains($url, 'example.com') пропустить example.com.attacker.io).

3. Не передавати URL у параметрі взагалі: зберігати ціль у сесії. Саме так працює Laravel: middleware auth запам'ятовує запитану сторінку (url.intended), а після входу redirect()->intended('/dashboard') повертає туди - URL не проходить через параметр, який може змінити нападник.

4. Ідентифікатори замість адрес: ?next=orders з мапою на маршрути замість довільного URL.

Проміжна сторінка «Ви переходите на зовнішній сайт ...» - компроміс, якщо зовнішні переходи справді потрібні (наприклад, посилання в повідомленнях користувачів).

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

Автентифікація відповідає на питання «хто ви», авторизація - «що вам дозволено». Більшість реальних витоків даних - не злам паролів, а відсутня чи неповна перевірка доступу: OWASP Top 10 ставить «Broken Access Control» на перше місце.

Заборонено за замовчуванням (deny by default): доступ дозволяється, лише якщо є явне правило, що його дозволяє. Будь-яка непередбачена ситуація - відмова.

// погано: дозволено всім, крім явно заборонених
if ($user->is_banned) {
    abort(403);
}

// добре: заборонено всім, крім явно дозволених
if (! $user->can('update', $post)) {
    abort(403);
}

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

Перевірки - лише на сервері. Приховати кнопку «Видалити» в інтерфейсі - це UX, а не захист:

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

Сервер має перевіряти права на кожну дію, незалежно від того, що показує інтерфейс.

Практичні принципи:

  • перевірка на кожен запит, а не лише при вході на сторінку: права могли змінитися, а запит - бути підробленим;
  • централізовано: у Laravel - політики й гейти, а не розкидані if ($user->role === 'admin') по контролерах;
  • найменші права: користувач і сервіс отримують лише ті можливості, що потрібні для роботи;
  • перевіряти конкретний об'єкт, а не лише тип дії: «може редагувати пости» ≠ «може редагувати цей пост»;
  • відмова - без зайвих деталей: 403 чи 404 без пояснення, які саме права відсутні.

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

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

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

Приклади:

  • адмінка доступна за адресою /admin, і перевіряється лише вхід, а не роль;
  • ендпойнт POST /users/7/role не перевіряє, хто змінює роль;
  • поле is_admin можна передати у формі профілю (масове призначення);
  • роль зберігається на клієнті (у cookie без підпису, у localStorage) - і її можна змінити.

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

Приклади:

  • /invoices/1041 - змінив число в адресі й бачиш чужий рахунок;
  • PATCH /addresses/88 змінює адресу іншого клієнта;
  • завантаження файлу за прямим посиланням без перевірки власника.

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

Як захищатися:

Від вертикального:

  • маршрути адмінки - в окремій групі з перевіркою ролі на рівні групи (middleware, політики);
  • зміна ролей і прав - окремі ендпойнти з суворою авторизацією й журналом аудиту;
  • роль - на сервері, а не в даних від клієнта.

Від горизонтального:

  • пошук через власника: $request->user()->invoices()->findOrFail($id) - чужий запис просто не знайдеться;
  • політики з перевіркою $invoice->user_id === $user->id (чи належності до команди/організації);
  • обмеження вкладених маршрутів (scopeBindings) - дочірній запис має належати батьківському.

Комбінований випадок: менеджер однієї компанії (рівень «менеджер» - вертикально все гаразд) бачить дані іншої компанії (горизонтально - ні). У багатоорендних застосунках перевірка належності до орендаря (tenant) потрібна для кожної ролі, включно з адміністраторами клієнтів.

Тест-матриця: для кожного ендпойнта - запит від анонімного користувача, від звичайного користувача-власника, від іншого користувача того самого рівня й від нижчої ролі. Очікувані відповіді - явно в тестах.

Докладніше в документації: PortSwigger: контроль доступу

Гейти (gates) - замикання для дій, не прив'язаних до конкретної моделі:

// AppServiceProvider::boot()
Gate::define('view-reports', fn (User $user) => $user->hasRole('analyst'));
Gate::define('access-admin', fn (User $user) => $user->is_admin);

Політики (policies) - клас з правилами для однієї моделі:

php artisan make:policy PostPolicy --model=Post
class PostPolicy
{
    public function update(User $user, Post $post): bool
    {
        return $user->id === $post->author_id;
    }

    public function delete(User $user, Post $post): bool
    {
        return $user->id === $post->author_id || $user->is_moderator;
    }
}

Laravel знаходить політику за назвою (App\Models\Post → App\Policies\PostPolicy) або за атрибутом на моделі.

Як перевіряти:

// у контролері
Gate::authorize('update', $post);           // 403, якщо заборонено
if ($request->user()->cannot('delete', $post)) { abort(403); }

// у Blade - лише для відображення
@can('update', $post) <a href="...">Редагувати</a> @endcan

// у Form Request
public function authorize(): bool { return $this->user()->can('update', $this->route('post')); }

На маршрутах - middleware can:

Route::put('/posts/{post}', [PostController::class, 'update'])->can('update', 'post');
Route::get('/admin/reports', ReportController::class)->middleware('can:view-reports');
Route::post('/posts', [PostController::class, 'store'])->can('create', Post::class);

'post' - назва параметра маршруту: після прив'язки моделі в політику потрапить конкретний пост. Для дій без екземпляра (create) передається клас.

Що обрати:

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

Пастки:

  • @can у шаблоні лише ховає кнопку - перевірка на сервері (контролер, маршрут) обов'язкова;
  • методи політики повинні повертати bool (або Response): забутий return дає null - і доступ заборонено, що безпечно, але збиває з пантелику;
  • гості: для неавтентифікованого користувача гейти й політики за замовчуванням повертають false, якщо параметр користувача не оголошено як ?User.

Докладніше в документації: Laravel: авторизація через middleware

За замовчуванням Laravel автоматично відмовляє неавтентифікованим користувачам у будь-якому гейті чи методі політики: метод навіть не викликається, а результат - false. Це безпечне значення за замовчуванням.

Щоб гість міг пройти перевірку, параметр користувача оголошують необов'язковим:

class PostPolicy
{
    public function view(?User $user, Post $post): bool
    {
        if ($post->is_published) {
            return true;   // опубліковане бачать усі, включно з гостями
        }

        return $user !== null && $user->id === $post->author_id;   // чернетку - лише автор
    }
}
Gate::define('view-pricing', fn (?User $user) => true);

Типові сценарії:

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

Пастки:

1. ?User і забута перевірка на null:

public function view(?User $user, Post $post): bool
{
    return $user->id === $post->author_id || $post->is_published;   // помилка для гостя
}

Для гостя $user->id - звернення до властивості null: у PHP 8 це помилка (Attempt to read property "id" on null), а не false. Завжди перевіряти null першим.

2. Гостьовий доступ, відкритий занадто широко. Додали ?User у view, щоб показувати опубліковані пости, - і забули, що той самий метод використовується для перегляду чернеток у прев'ю. Кожен метод з ?User варто переглядати окремо: які гілки повертають true для гостя.

3. Неявна довіра до параметрів: «опубліковане» має визначатися полем у базі (is_published), а не параметром запиту (?preview=1).

4. Авторизація ≠ автентифікація маршруту. Middleware auth на маршруті відсіює гостей ще до політики. Якщо маршрут має бути доступним гостям частково, auth на ньому не ставлять, а розрізнення робить політика.

Тести для гостьових сценаріїв: перевірити, що гість бачить лише дозволене ($this->get(...)->assertOk() для опублікованого, assertForbidden()/assertNotFound() для чернетки), - саме тут найчастіше випадково відкривають приватні дані.

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

Проблема: рахунки, договори, вкладення листування лежать у сховищі, а посилання на них мають вигляд /storage/invoices/1041.pdf. Хто знає чи вгадає адресу, той завантажить файл - без перевірки прав.

Перше правило: приватні файли - не в публічній папці (public/, storage/app/public з посиланням storage:link), а на приватному диску. Віддає їх контролер з перевіркою прав:

Route::get('/invoices/{invoice}/download', function (Invoice $invoice) {
    Gate::authorize('download', $invoice);

    return Storage::disk('private')->download($invoice->path, "invoice-{$invoice->number}.pdf");
})->middleware('auth');

Підписані URL - коли файл треба віддати без входу (посилання в листі, вбудування в сторонній сервіс) або на обмежений час:

$url = URL::temporarySignedRoute(
    'invoices.download',
    now()->plus(hours: 24),
    ['invoice' => $invoice->id],
);
Route::get('/invoices/{invoice}/download', DownloadInvoiceController::class)
    ->name('invoices.download')
    ->middleware('signed');
  • до URL додаються expires і signature - HMAC від усієї адреси з ключем застосунку (APP_KEY);
  • зміна будь-якого параметра (invoice=1042) чи терміну дії робить підпис недійсним - 403;
  • після закінчення терміну посилання перестає працювати.

Що важливо розуміти:

  • підписаний URL - це «пред'явницький» доступ: будь-хто з посиланням отримає файл. Переслане, збережене в історії, потрапило в лог - доступ має кожен. Тому: короткий термін дії, а для чутливих документів - підпис плюс перевірка входу й прав;
  • ключ підпису - APP_KEY: його зміна робить недійсними всі видані посилання;
  • за проксі (Cloudflare, балансувальник) адреса, яку бачить Laravel, може відрізнятися від підписаної (схема, хост) - тоді допомагає signed:relative і підпис відносного URL (absolute: false);
  • одноразовість підписані URL не забезпечують - для одноразових посилань потрібна позначка в базі.

Для файлів у S3/R2 аналог - Storage::temporaryUrl(): підписане посилання сховища, і файл віддається напряму з нього, минаючи сервер застосунку.

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

Секрети - паролі до бази, APP_KEY, ключі API, токени платіжних систем - не повинні жити в коді. У Laravel їх зберігають у файлі .env, який:

  • ніколи не комітиться - він уже є в .gitignore стандартного проєкту;
  • має шаблон .env.example у репозиторії - з назвами змінних, але без справжніх значень;
  • на сервері доступний лише користувачу, від якого працює застосунок (chmod 600), і лежить поза публічним каталогом (public/).

У коді секрети читаються лише через конфігурацію: config('services.stripe.secret'), а env() - тільки у файлах config/*.php. Після php artisan config:cache виклики env() поза конфігурацією повертають null.

Якщо .env потрапив у Git (навіть на хвилину, навіть у приватний репозиторій):

  1. вважати всі секрети з файлу скомпрометованими і змінити їх - згенерувати нові ключі API, паролі до бази, новий токен бота. Це головний крок;
  2. видалити файл з репозиторію й додати в .gitignore;
  3. за потреби переписати історію (git filter-repo) - але це вже другорядне: копія могла лишитися у форках, клонах, кешах CI, на GitHub у вигляді доступних за хешем комітів.

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

Профілактика:

  • сканування секретів перед пушем: GitHub secret scanning з push protection, gitleaks чи trufflehog у pre-commit або CI;
  • окремі ключі для кожного оточення (локальне, staging, продакшен) - витік локальних ключів не зачіпає продакшен;
  • ключі з найменшими правами - токен, якому потрібно лише читати, не повинен уміти писати;
  • для командної роботи - зашифрований .env (php artisan env:encrypt) чи менеджер секретів, а не пересилання файлу в месенджері.

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

Інструменти, що допомагають під час розробки, на продакшені стають готовою розвідкою для зловмисника: вони показують запити до бази, змінні оточення, сесії, токени й внутрішню структуру застосунку. Сканери шукають їхні адреси автоматично.

Що перевірити перед запуском:

Що Чим небезпечне відкрите Як закрити
Laravel Telescope (/telescope) запити з тілами, SQL, винятки, вміст сесій і кешу лише --dev залежність або gate viewTelescope з переліком адмінів
Laravel Horizon (/horizon) дані завдань у черзі (часто з email чи id), можливість повторити чи видалити завдання Gate::define('viewHorizon', ...) - за замовчуванням на не-локальному середовищі доступу немає ні в кого, але ворота часто «відкривають» для зручності
Debugbar SQL з параметрами, сесія, конфігурація на кожній сторінці лише в require-dev, APP_DEBUG=false
Сторінка винятку (APP_DEBUG=true) стек викликів, шляхи, фрагменти коду, змінні оточення APP_DEBUG=false на продакшені
phpinfo(), info.php версії, модулі, змінні середовища, іноді ключі не тримати такі файли в public/
Pulse, Log Viewer та інші панелі метрики, логи з персональними даними авторизація через Gate
.env, .git, storage/, composer.json секрети й вихідний код корінь вебсервера - лише public/
адмінка й Filament повний доступ до даних canAccessPanel(), двофакторна автентифікація, бажано обмеження за IP чи VPN

Принципи:

  • dev-пакети - у require-dev, а на продакшені composer install --no-dev: якщо пакета немає, його неможливо випадково відкрити;
  • ворота (Gate) замість «таємної адреси» - змінений шлях /telescope-x7k2 не захист, його знаходять перебором і з логів;
  • перевірка ззовні: після деплою відкрити ці адреси без входу (чи прогнати сканер) - очікується 404 чи 403, а не сторінка інструменту;
  • автоматичний тест: Pest-тест, що гість отримує 403 на /horizon і /telescope, ловить випадкову зміну воріт ще до продакшену.

Додатковий шар - WAF чи правило на рівні проксі, що блокує типові службові шляхи (/phpinfo, /.env, /_ignition, /telescope) для всіх, крім адміністраторів. Але це доповнення до закритих інструментів, а не заміна.

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

Застосунок часто підключається до бази під обліковим записом з усіма правами - root у MySQL чи postgres у PostgreSQL. Поки все працює, різниці не видно. Різниця з'являється, коли щось іде не так.

Що дає зловмиснику всемогутній користувач:

  • через SQL-ін'єкцію - не лише читання даних, а DROP DATABASE, читання й запис файлів сервера (LOAD DATA, COPY ... TO PROGRAM у PostgreSQL для суперкористувача), створення нових користувачів;
  • через вкрадений .env - повний контроль над усіма базами на сервері, а не лише над даними застосунку.

Принцип найменших прав - кожен обліковий запис має лише те, що йому справді потрібно:

Користувач Права
застосунок SELECT, INSERT, UPDATE, DELETE на свою базу
міграції (деплой) + CREATE, ALTER, DROP, INDEX на свою базу
аналітика, звіти лише SELECT, бажано на репліці
бекап читання й блокування, без зміни даних
-- PostgreSQL
CREATE ROLE app LOGIN PASSWORD '...';
GRANT CONNECT ON DATABASE shop TO app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app;

Інші правила:

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

Практичний компроміс у Laravel: міграції часто запускають тим самим користувачем, що й застосунок. Окреме підключення для міграцій (--database=migrations) з ширшими правами - кращий варіант для продакшену.

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

У Laravel-проєкті кореневий каталог сайту (document root) - лише public/. Там лежать index.php і зібрані ассети. Усе інше - код, .env, storage/, vendor/, .git/ - має бути поза доступом вебсервера.

Типова помилка - document root вказує на корінь проєкту:

root /var/www/shop;          # неправильно
root /var/www/shop/public;   # правильно

Тоді за прямими посиланнями доступні:

  • /.env - паролі до бази, APP_KEY, ключі API. Сканери перевіряють цей шлях на мільйонах сайтів щодня;
  • /.git/ - з каталогу .git інструменти на кшталт git-dumper відновлюють увесь вихідний код і історію комітів, включно з колись видаленими секретами;
  • /storage/logs/laravel.log - стеки помилок, інколи персональні дані;
  • /composer.json, composer.lock - точні версії залежностей для пошуку відомих вразливостей;
  • резервні копії й дампи (backup.sql, .env.bak, index.php~), залишені в публічних каталогах.

Як захиститися:

  • document root = public/ - головне правило;
  • у Nginx - заборона прихованих файлів, крім .well-known (потрібен для сертифікатів Let's Encrypt і security.txt):
location ~ /\.(?!well-known).* {
    deny all;
}
  • не класти в public/ нічого, крім того, що має бути публічним. Файли користувачів - через storage:link лише для публічного диска, приватні - через контролер з перевіркою прав;
  • не деплоїти .git на сервер, якщо він не потрібен (збірка артефакту в CI);
  • регулярна перевірка ззовні: curl -I https://example.com/.env має повертати 404 чи 403. Такі перевірки варто додати в моніторинг.

WAF-правила (наприклад, у Cloudflare), що блокують запити до .env, .git, wp-admin, phpmyadmin, - корисний додатковий рубіж, але не заміна правильному document root.

Докладніше в документації: Laravel: налаштування Nginx

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

Правило 3-2-1:

  • 3 копії даних (основна + дві резервні);
  • на 2 різних носіях чи типах сховищ;
  • 1 копія поза основною інфраструктурою - інший провайдер, регіон, обліковий запис.

Захист від вимагачів і зловмисників:

  • зловмисник, що отримав доступ до сервера, видалить і бекапи, якщо сервер має права на їх видалення. Тому:
    • сервер має лише право дописувати нові копії, але не видаляти чи перезаписувати старі;
    • незмінні копії - Object Lock в S3-сумісних сховищах (режим compliance), версіонування бакетів;
    • окремий обліковий запис для бекапів, до якого немає доступу з продакшену;
  • офлайн- чи ізольовані копії - CISA рекомендує тримати хоча б одну копію поза мережею.

Шифрування: бекапи містять усі персональні дані й секрети. Вони мають бути зашифровані, а ключ - зберігатися окремо від бекапів (і не лише на тому самому сервері).

Що бекапити:

  • базу даних (дамп чи фізична копія + журнали для відновлення на момент у часі);
  • файли користувачів (storage/app, бакет S3);
  • конфігурацію й секрети (зашифрованими) - без них відновлений застосунок не запуститься.

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

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

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

Моніторинг: сповіщення, якщо бекап не створився чи його розмір різко змінився (і раптове зменшення - ознака проблеми).

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

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

Рівні
Junior 35 Middle 37 Senior 31

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