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

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

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

37 питань

CSRF (Cross-Site Request Forgery) - чужий сайт змушує браузер користувача надіслати запит на ваш сайт. Браузер автоматично додає cookie вашого сайту, тож запит виглядає як справжній запит залогіненого користувача.

<!-- на evil.example -->
<form action="https://bank.example/transfer" method="POST">
  <input type="hidden" name="to" value="attacker">
  <input type="hidden" name="amount" value="10000">
</form>
<script>document.forms[0].submit()</script>

Нападник не бачить відповіді, але дія виконується.

Захист 1 - CSRF-токен. Сервер кладе в сесію випадковий токен і вимагає його в кожному запиті, що змінює стан. Чужий сайт не може прочитати токен (same-origin policy), тож не може його підставити.

<form method="POST" action="/transfer">
    @csrf
</form>

Laravel перевіряє токен автоматично для всіх POST/PUT/PATCH/DELETE у групі web.

Захист 2 - атрибут cookie SameSite:

  • Lax (його ставить Laravel; браузери на Chromium застосовують його й до cookie без атрибута) - cookie не надсилається в міжсайтових POST-запитах, лише при звичайних переходах за посиланнями (GET).
  • Strict - не надсилається в жодних міжсайтових запитах.

Чому потрібні обидва: SameSite не захищає від запитів з піддоменів того самого сайту, а старі браузери його не підтримують. Токен - основний захист, SameSite - додатковий рівень.

Важливо: GET-запити не мають змінювати стан. GET /logout чи GET /delete?id=5 легко викликати картинкою на чужій сторінці.

API з токенами в заголовку Authorization (не в cookie) до CSRF не вразливі: браузер не додає такий заголовок автоматично.

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

SSRF (Server-Side Request Forgery) - нападник змушує ваш сервер зробити запит туди, куди йому самому не дістатися: до внутрішніх сервісів, адмінок без авторизації, метаданих хмари.

Типові функції-мішені: імпорт аватара за URL, попередній перегляд посилання, вебхуки з адресою від користувача, конвертація HTML у PDF.

// Вразливо: адресу задає користувач
$image = Http::get($request->input('avatar_url'))->body();
// avatar_url = http://169.254.169.254/latest/meta-data/iam/security-credentials/
// → сервер віддасть тимчасові ключі хмари

Інші цілі: http://localhost:6379 (Redis без пароля), http://10.0.0.5:8080/admin, file:///etc/passwd у бібліотеках, що підтримують інші схеми.

Захист:

  • Білий список, якщо можливо: дозволені лише конкретні домени.
  • Лише http/https, жодних file:, gopher:, ftp:.
  • Перевірка IP після резолву DNS: відхиляти приватні діапазони (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), 127.0.0.0/8, 169.254.0.0/16, IPv6-аналоги. Перевіряти саме той IP, до якого піде з'єднання, - інакше обхід через DNS rebinding (домен спершу резолвиться в публічну адресу, а при з'єднанні - у внутрішню).
  • Редиректи: вимкнути або перевіряти кожен, бо https://evil.example може перенаправити на http://127.0.0.1.
  • Мережева ізоляція: вихідні запити через окремий проксі чи сервіс без доступу до внутрішньої мережі.
  • У хмарі - IMDSv2 на AWS (вимагає токен, який SSRF зазвичай не отримає).

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

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

SSTI (server-side template injection) - дані користувача потрапляють у сам шаблон, а не в змінну, яку шаблон виводить. Рушій шаблонів компілює їх як код.

// вразливо: текст від користувача стає шаблоном
return Blade::render($request->input('greeting'), ['user' => $user]);
// greeting = "{{ system('cat .env') }}"  - або @php ... @endphp

Blade компілюється в PHP, тож SSTI в Blade - це виконання довільного PHP-коду на сервері.

Різниця, яку треба розуміти:

// безпечно: шаблон - ваш, дані - змінна, екранована {{ }}
return view('emails.welcome', ['greeting' => $request->input('greeting')]);

// небезпечно: дані користувача - частина шаблону
Blade::render($userTemplate, $data);

Де таке трапляється на практиці:

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

Як робити безпечно:

  • користувацьким шаблонам - лише заміна плейсхолдерів, а не повноцінний рушій:
$text = strtr($template, [
    '{name}' => e($user->name),
    '{order}' => e($order->number),
]);
  • якщо потрібна логіка (умови, цикли) - рушій з пісочницею і білим списком дозволених функцій (наприклад, Twig Sandbox), обмежений набір змінних, без доступу до об'єктів з методами;
  • Markdown замість HTML/Blade для контенту від користувачів - і рендер із санітизацією;
  • ніколи Blade::render, eval, create_function-подібних механізмів з даними ззовні.

Схоже на SSTI на клієнті: вираз {{ }} у даних, які Vue чи Angular компілюють як шаблон на сторінці (Vue змонтований на розмітку з користувацьким вмістом), - client-side template injection, що дає XSS.

Докладніше в документації: PortSwigger: Server-side template injection

XXE (XML External Entity) - атака через XML з визначенням зовнішньої сутності, яка вказує на файл чи URL. Парсер, що підставляє такі сутності, вставляє в документ вміст файлу сервера чи відповідь внутрішнього сервісу.

<?xml version="1.0"?>
<!DOCTYPE order [
  <!ENTITY secret SYSTEM "file:///var/www/app/.env">
]>
<order><comment>&secret;</comment></order>

Якщо застосунок потім показує чи зберігає comment - зловмисник отримує вміст .env. Варіанти - SSRF через http:// у сутності, «сліпий» XXE з передачею даних на зовнішній сервер, DoS «мільярдом сміхів» (експоненційно вкладені сутності).

Де трапляється XML: імпорт прайсів і фідів, SOAP-інтеграції, SAML (вхід через корпоративний SSO), формати Office (DOCX, XLSX - це zip з XML), SVG.

Сучасний PHP за замовчуванням безпечніший. З libxml 2.9 зовнішні сутності не підставляються, якщо їх не ввімкнути явно. Небезпечні - прапорці:

$doc = new DOMDocument();
$doc->loadXML($xml);                     // за замовчуванням сутності не розкриваються
$doc->loadXML($xml, LIBXML_NOENT);       // НЕБЕЗПЕЧНО: розкриває сутності, зокрема зовнішні
$doc->loadXML($xml, LIBXML_DTDLOAD);     // НЕБЕЗПЕЧНО: завантажує зовнішні DTD

Назва LIBXML_NOENT вводить в оману: «no entities» означає «замінити сутності їхнім вмістом», тобто ввімкнути підстановку. Його часто додають «щоб працювали &amp;-подібні сутності» - і відкривають XXE.

Функцію libxml_disable_entity_loader() оголошено застарілою в PHP 8.0 - вона більше не потрібна для захисту за замовчуванням.

Правила:

  • не використовувати LIBXML_NOENT і LIBXML_DTDLOAD з даними ззовні;
  • відхиляти документи з <!DOCTYPE>, якщо формату він не потрібен;
  • обмежувати розмір XML і глибину вкладеності;
  • бібліотеки для SAML і SOAP - актуальні версії: XXE в них - відомий клас вразливостей;
  • SVG від користувачів - окрема тема: окрім XXE під час обробки на сервері, SVG може містити скрипти (XSS при відкритті).

JSON замість XML, де це можливо, - менше поверхня атаки: у JSON немає сутностей і DTD.

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

У JavaScript об'єкти успадковують властивості від прототипу, і всі звичайні об'єкти мають спільний Object.prototype. Забруднення прототипу - зловмисник через дані змушує код записати властивість у сам прототип, і вона з'являється в усіх об'єктах застосунку.

Як це трапляється - рекурсивне злиття чи встановлення властивості за шляхом з даних користувача:

function merge(target, source) {
  for (const key in source) {
    if (typeof source[key] === 'object') {
      target[key] ??= {};
      merge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
}

merge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'));

({}).isAdmin;   // true - у кожного об'єкта

Ключ __proto__ вказує на прототип, тож запис іде в Object.prototype. Так само через constructor.prototype.

Наслідки:

  • обхід перевірок: if (user.isAdmin) для об'єкта, в якому поля немає, раптом дає true;
  • XSS у браузері: бібліотека читає налаштування з об'єкта опцій (options.innerHTML, options.src), а «забруднене» значення підставляється в DOM. Так атакують через параметри URL (?__proto__[x]=...) з бібліотеками, що розбирають рядок запиту у вкладені об'єкти;
  • виконання коду на сервері (Node.js): забруднені опції child_process, рушіїв шаблонів;
  • DoS - зламана логіка всього застосунку.

Захист:

  • блокувати небезпечні ключі при злитті й записі за шляхом: __proto__, constructor, prototype;
  • об'єкти без прототипу для словників з даних: Object.create(null);
  • Map замість звичайного об'єкта для довільних ключів;
  • валідація схемою (Zod) з «суворими» об'єктами - невідомі ключі відкидаються;
  • оновлювати бібліотеки злиття, розбору рядка запиту, глибокого копіювання - багато відомих CVE саме тут (lodash merge, qs, jQuery extend);
  • Object.freeze(Object.prototype) - радикальний захист, але може зламати сторонні бібліотеки.

Чим це стосується PHP-розробника: фронтенд Laravel-застосунку (Vue, React, Alpine) і Node-скрипти збирання/SSR - теж частина поверхні атаки; оновлення npm-залежностей важливе так само, як composer.

Докладніше в документації: PortSwigger: Prototype pollution

Сесія: сервер зберігає стан (в базі, Redis, файлах), а клієнт має лише випадковий ID у cookie. Кожен запит - пошук сесії за ID.

  • Миттєве відкликання: видалили сесію - доступу немає.
  • Потрібне спільне сховище для кількох серверів.

JWT: токен сам містить дані (ID користувача, ролі, термін дії) і підписаний сервером. Перевірка - лише підпис, без звернення до сховища.

  • Зручно між сервісами й доменами: будь-який сервіс з ключем перевірки довіряє токену.
  • Відкликати до закінчення терміну складно: токен дійсний, поки не сплив. Звідси короткий термін access-токена (хвилини) плюс refresh-токен, або список відкликаних - що повертає стан на сервер.
  • Дані в JWT не зашифровані, лише підписані: base64 розкодує будь-хто.

Де зберігати токен у браузері:

  • localStorage - доступний будь-якому JavaScript на сторінці. Одна XSS-вразливість - і токен викрадено й використано з іншого місця.
  • Cookie з HttpOnly, Secure, SameSite - JavaScript токен не бачить. XSS усе ще може робити запити від імені користувача, але не може винести токен. Потрібен захист від CSRF (SameSite + токен).

Для власного SPA на тому самому домені найпростіше й надійніше - звичайна сесія в cookie (як Sanctum у SPA-режимі). JWT і bearer-токени доречні для мобільних застосунків, інтеграцій сервер-сервер, мікросервісів.

Пастки JWT (RFC 8725): приймати лише очікуваний алгоритм (атака з alg: none і підміною алгоритму), перевіряти exp, iss, aud, не класти в токен секретних даних.

Докладніше в документації: RFC 8725: найкращі практики JWT

Фіксація сесії (session fixation): нападник заздалегідь отримує ID сесії (просто відкривши сайт) і змушує жертву використовувати цей самий ID - через посилання з параметром, вразливість на піддомені, що встановлює cookie. Жертва входить в обліковий запис, сесія стає автентифікованою - а її ID нападник уже знає.

Захист - новий ID сесії при зміні рівня привілеїв: після входу, після підвищення прав (вхід в адмінку, підтвердження пароля), після виходу.

if (Auth::attempt($credentials)) {
    $request->session()->regenerate();   // новий ID, дані сесії збережено
    return redirect()->intended('/dashboard');
}

// вихід
Auth::logout();
$request->session()->invalidate();       // знищити сесію
$request->session()->regenerateToken();  // новий CSRF-токен

Стартові набори Laravel (Breeze, Jetstream, Fortify) роблять це автоматично; у власній логіці входу про це легко забути.

Інші правила керування сесіями:

  • ID лише в cookie (HttpOnly, Secure, SameSite), ніколи в URL: звідти він потрапляє в логи, історію браузера й заголовок Referer.
  • Не приймати ID сесії, які сервер не видавав (строгий режим, session.use_strict_mode у PHP).
  • Тайм-аути: неактивності (наприклад, 30 хвилин для чутливих застосунків) і абсолютний.
  • Вихід на всіх пристроях після зміни пароля (Auth::logoutOtherDevices() у Laravel).
  • Повторне підтвердження пароля перед критичними діями - зміною email, видаленням облікового запису.

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

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

Вимоги до токена скидання:

  • випадковий і довгий - криптографічно стійкий генератор (random_bytes, Str::random()), не похідний від email, часу чи id;
  • одноразовий - після використання недійсний;
  • короткий термін дії - хвилини чи година;
  • у базі - хеш токена, а не сам токен: витік бази не дає активних посилань для скидання;
  • прив'язаний до облікового запису - токен одного користувача не працює для іншого.

Процес:

  1. користувач вводить email - відповідь однакова незалежно від того, чи існує обліковий запис («Якщо обліковий запис існує, ми надіслали лист»);
  2. лист із посиланням, що містить токен; відправка - через чергу (щоб час відповіді не видавав існування облікового запису);
  3. перехід за посиланням - форма нового пароля; токен перевіряється порівнянням хешів у постійному часі;
  4. новий пароль - з тими самими правилами, що й при реєстрації (довжина, перевірка у базі витоків);
  5. після зміни: токен знищено, усі інші сесії й токени API користувача відкликано, лист-сповіщення «ваш пароль змінено».

Пастки:

  • отруєння посилання через заголовок Host: якщо URL у листі будується з Host запиту, а сервер приймає довільні хости, зловмисник ініціює скидання для жертви з підробленим Host: evil.example - жертва отримує справжній лист з посиланням на домен зловмисника. Захист - фіксований APP_URL для посилань і перевірка дозволених хостів;
  • витік токена через Referer: сторінка скидання завантажує сторонні ресурси (аналітику, шрифти) - токен з URL потрапляє в Referer. Referrer-Policy: no-referrer на цій сторінці;
  • відповідь на секретні питання замість пошти - слабкий механізм, відповіді легко знайти в соцмережах;
  • автоматичний вхід після скидання без MFA - обхід другого фактора;
  • необмежена кількість запитів - спам листами на чужу адресу.

У Laravel брокер паролів (Password::sendResetLink, Password::reset) зберігає хеш токена в password_reset_tokens, має термін дії й обмеження частоти (throttle у config/auth.php). Після скидання варто викликати Auth::logoutOtherDevices() чи прибрати сесії й токени Sanctum.

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

Passkey - облікові дані на основі криптографії з відкритим ключем (стандарт WebAuthn/FIDO2) замість пароля.

Як це працює:

  1. реєстрація: пристрій користувача (телефон, ноутбук, апаратний ключ) створює пару ключів для конкретного сайту. Приватний ключ лишається на пристрої (в захищеному сховищі), сайт отримує й зберігає лише публічний;
  2. вхід: сервер надсилає випадковий виклик (challenge), пристрій підписує його приватним ключем після локального підтвердження (відбиток, обличчя, PIN пристрою), сервер перевіряє підпис публічним ключем.

Чому це сильніше за пароль + код:

  • стійкість до фішингу. Ключ прив'язаний до домену сайту (relying party ID). Браузер просто не дасть використати ключ для laravelukraine.com на сторінці laravelukraine-login.com. Фальшивий сайт нічого не отримає - на відміну від пароля й навіть TOTP-коду, які користувач введе куди завгодно;
  • нічого вкрасти з сервера: у базі лише публічні ключі - їх витік нікому не допомагає;
  • немає повторного використання: кожен сайт має свою пару ключів;
  • немає перебору й credential stuffing - немає пароля;
  • два фактори в одному: «маю пристрій» + «біометрія/PIN пристрою».

Синхронізовані й прив'язані до пристрою:

  • синхронізовані passkeys (iCloud Keychain, Google Password Manager, 1Password) - доступні на всіх пристроях користувача, не губляться разом з телефоном;
  • прив'язані до пристрою (апаратні ключі YubiKey) - максимальна безпека, але потрібен резервний ключ.

Що врахувати при впровадженні:

  • відновлення доступу: втратили всі пристрої - потрібен запасний шлях (резервний passkey, перевірена пошта з додатковими перевірками), і він не повинен бути слабшим за сам passkey;
  • поступовий перехід: спершу passkey як додатковий спосіб входу чи другий фактор, пароль поки лишається;
  • кілька passkeys на обліковий запис - з назвами пристроїв і можливістю видалити;
  • бібліотеки: реалізовувати WebAuthn вручну не варто - перевірка підпису, лічильників, атестації має багато тонкощів. Для PHP/Laravel є готові пакети, на фронтенді - API браузера navigator.credentials.

Підтримка: усі сучасні браузери й операційні системи; великі сервіси (Google, Apple, GitHub, Microsoft) уже пропонують вхід без пароля.

Докладніше в документації: Passkeys.dev: що таке passkeys

«Запам'ятати мене» - користувач лишається автентифікованим тижні й місяці, навіть після закриття браузера, коли звичайна сесія вже завершилася.

Як це роблять правильно:

  • окремий довгоживучий токен у cookie - випадковий, криптографічно стійкий, а не email, id чи хеш пароля;
  • у базі - хеш токена (чи токен, прив'язаний до користувача, як у Laravel - remember_token), щоб витік бази не давав готових cookie;
  • cookie з HttpOnly, Secure, SameSite - недоступна JavaScript і не передається по HTTP;
  • при використанні токен створює нову звичайну сесію;
  • при виході токен знищується на сервері, а не лише стирається cookie в браузері.

Ризики довгих сесій і що з ними робити:

  • викрадена cookie діє довго. Обмежити абсолютний термін (наприклад, 30 днів), а для чутливих дій вимагати повторного підтвердження паролем (зміна email, пароля, MFA, платіжних даних) - так працює password.confirm middleware в Laravel;
  • вихід «з усіх пристроїв» має справді анулювати remember-токени. У Laravel зміна remember_token (наприклад, при зміні пароля чи Auth::logoutOtherDevices()) робить старі cookie недійсними;
  • «ротація» токена при кожному використанні з виявленням повторного використання - так помічають, що cookie скопійовано;
  • список активних сесій у налаштуваннях облікового запису з пристроєм, місцем і часом останньої активності - користувач бачить чужий вхід і може його завершити (драйвер сесій database дає таку можливість).

Таймаути сесій (рекомендації OWASP):

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

Регенерація ідентифікатора сесії після входу, зміни прав і виходу - захист від фіксації сесії ($request->session()->regenerate()).

Компроміс: «Запам'ятати мене» - зручність коштом безпеки. Для публічних і спільних комп'ютерів його не варто вмикати за замовчуванням; для застосунків з чутливими даними варто обмежити або вимагати MFA при відновленні сесії з remember-токена.

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

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

  • cookie: сесійна cookie й інші cookie, які шифрує middleware EncryptCookies;
  • сесії (якщо SESSION_ENCRYPT=true);
  • підписані URL (URL::signedRoute, посилання підтвердження email);
  • дані, зашифровані через Crypt та касти encrypted у моделях;
  • сторонні механізми, що на нього спираються (наприклад, підпис знімків стану Livewire).

Генерується командою php artisan key:generate (AES-256, 32 байти, у .env як base64:...).

Чому ключ не можна просто замінити: після зміни все, що зашифровано старим ключем, перестає розшифровуватися:

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

Плавна ротація: старі ключі перелічуються в APP_PREVIOUS_KEYS:

APP_KEY=base64:новий...
APP_PREVIOUS_KEYS=base64:старий...

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

Повна ротація (наприклад, після витоку ключа):

  1. новий ключ у APP_KEY, старий - у APP_PREVIOUS_KEYS;
  2. перешифрувати дані в базі новим ключем (команда, що читає й зберігає кожне зашифроване поле);
  3. через час, достатній для завершення сесій і закінчення терміну посилань, прибрати старий ключ з APP_PREVIOUS_KEYS.

Правила зберігання:

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

Не плутати з хешуванням паролів: паролі хешуються (bcrypt/argon2) без APP_KEY - зміна ключа на них не впливає.

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

Для полів, витік яких особливо шкідливий (токени сторонніх API, секрети 2FA, номери документів, медичні нотатки), Laravel має касти, що шифрують значення на рівні застосунку:

protected function casts(): array
{
    return [
        'two_factor_secret' => 'encrypted',
        'api_credentials' => 'encrypted:array',
        'medical_notes' => 'encrypted',
        'settings' => AsEncryptedArrayObject::class,
    ];
}

У базі зберігається зашифрований рядок (AES-256 з APP_KEY і MAC для перевірки цілісності). Модель автоматично шифрує при записі й розшифровує при читанні.

Від чого це захищає:

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

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

Обмеження, про які треба знати:

  • не можна шукати й сортувати: where('passport_number', $value) не знайде запис - кожне шифрування дає різний шифротекст (випадковий IV). Якщо пошук потрібен - окрема колонка з «сліпим індексом»: HMAC від значення з окремим ключем, і пошук за ним;
  • не можна індексувати, агрегувати, використовувати в unique-обмеженнях бази;
  • розмір колонки: шифротекст (base64 з IV і MAC) значно довший за значення - колонка text, а не varchar(50);
  • ротація APP_KEY вимагає перешифрування всіх таких полів; без старого ключа в APP_PREVIOUS_KEYS дані втрачено;
  • продуктивність: розшифрування при кожному читанні - помітно на великих вибірках.

Окремий ключ для шифрування даних: можна вказати власний шифрувальник для моделей (Model::encryptUsing($encrypter)), щоб ключ даних був окремо від APP_KEY і ротувався незалежно.

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

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

Підписаний URL містить параметр signature - HMAC від адреси, обчислений з APP_KEY. Будь-яка зміна URL (інший id, інший термін) робить підпис недійсним.

use Illuminate\Support\Facades\URL;

// безстроковий
$url = URL::signedRoute('unsubscribe', ['user' => $user->id]);

// з терміном дії
$url = URL::temporarySignedRoute('invoices.download', now()->plus(hours: 24), ['invoice' => $invoice->id]);
// https://example.com/invoices/42/download?expires=1760000000&signature=9a1f...

Перевірка на маршруті:

Route::get('/invoices/{invoice}/download', DownloadInvoice::class)
    ->name('invoices.download')
    ->middleware('signed');

Недійсний чи прострочений підпис - відповідь 403. Вручну - $request->hasValidSignature().

Де доречні:

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

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

  • підпис захищає від зміни URL, а не від передачі. Хто має посилання, той має доступ. Тому для чутливих дій - короткий термін, а для «чарівних посилань» входу - ще й одноразовість (позначка в базі, що посилання використано);
  • підписується вся адреса з параметрами. Додатковий параметр, доданий після підписання (?utm_source=... від поштового сервісу), зламає підпис. Для таких випадків - hasValidSignatureWhileIgnoring(['utm_source']) або signed:relative для підпису лише шляху;
  • підпис залежить від APP_KEY і домену - зміна ключа без APP_PREVIOUS_KEYS зламає всі видані посилання; за проксі Laravel має знати правильну схему й хост (trustProxies), інакше перевірка https-посилання на http-запиті не пройде;
  • підписане посилання потрапляє в історію браузера, логи й поле Referer - не кладіть туди нічого, що має лишатися секретом довше за термін дії.

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

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

Два типи файлів - два способи зберігання:

  • публічні (аватари, зображення статей) - диск public чи публічний бакет; доступні за прямим URL усім;
  • приватні (рахунки, документи, вкладення в листуванні) - диск local (каталог storage/app/private) чи приватний бакет; недоступні за прямим URL.

Типова помилка - класти все на public. php artisan storage:link робить storage/app/public доступним з вебу. Документ, збережений туди, відкривається будь-ким, хто вгадає чи отримає шлях - а шляхи потрапляють у листи, логи, історію браузера.

Віддача приватних файлів - через контролер з перевіркою прав:

Route::get('/documents/{document}', function (Document $document) {
    Gate::authorize('view', $document);

    return Storage::disk('private')->download($document->path, $document->original_name);
})->middleware('auth');

Для хмарних сховищ - тимчасове підписане посилання, файл віддає саме сховище:

return redirect(Storage::disk('s3')->temporaryUrl($document->path, now()->plus(minutes: 5)));

Правила збереження:

  • власні назви файлів - $file->store('documents') генерує випадкову назву. Оригінальну назву зберегти в базі для показу, але не використовувати як шлях (обхід шляху, перезапис чужих файлів, спецсимволи);
  • перевірка вмісту, а не розширення: правило mimes:pdf,jpg перевіряє MIME за вмістом; extensions: - лише розширення. Для зображень корисно ще й перекодувати (знімає вбудовані скрипти й метадані з геолокацією);
  • обмеження розміру - правило max: і ліміти PHP/вебсервера;
  • жодного виконання в каталогах завантажень: вебсервер не повинен виконувати .php з storage чи публічних каталогів;
  • Content-Disposition: attachment для файлів, які не повинні відкриватися в браузері (HTML чи SVG від користувача, відкриті як сторінка вашого домену, - XSS).

Видимість у хмарі: у Laravel/Flysystem файли за замовчуванням приватні; 'visibility' => 'public' - свідоме рішення. Бакет без публічного доступу на рівні налаштувань провайдера - додатковий захист від помилки в коді.

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

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

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

Де вони з'являються:

  • стек винятку містить аргументи функцій - пароль, переданий у login($email, $password), опиниться у звіті про помилку;
  • контекст запиту в Sentry/Flare/Bugsnag - тіло запиту, заголовки (Authorization, Cookie);
  • власні логи - Log::info('Login attempt', $request->all());
  • Telescope і Debugbar - записують запити, SQL з параметрами, сесії;
  • серіалізовані моделі в логах і черзі - усі атрибути, крім $hidden.

Інструменти Laravel і PHP:

1. #[\SensitiveParameter] (PHP 8.2+) - значення параметра не потрапить у стек винятку:

public function attempt(string $email, #[\SensitiveParameter] string $password): bool

Laravel позначає так паролі у своїх методах автентифікації.

2. $hidden у моделях - атрибути, що не серіалізуються в масив і JSON (password, remember_token, two_factor_secret).

3. dontFlash у налаштуванні винятків - поля, які не повертаються в сесію після помилки валідації (за замовчуванням password, password_confirmation, current_password):

$exceptions->dontFlash(['card_number', 'cvv']);

4. Налаштування сервісів моніторингу - фільтри полів і заголовків перед відправкою (у Sentry - before_send, списки чутливих ключів).

5. Telescope на продакшені - вимкнений або з фільтрами (Telescope::hideRequestParameters([...]), hideRequestHeaders([...])) і доступом лише для адміністраторів.

Правила для власного логування:

  • логувати ідентифікатори, а не дані: user_id, order_id, а не email, адресу, номер картки;
  • ніколи $request->all() у лог;
  • структуровані логи з явним переліком полів - простіше перевірити, що туди потрапляє;
  • термін зберігання логів - персональні дані в них теж підпадають під вимоги захисту даних.

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

Перевірка: раз на якийсь час переглядати реальні записи логів і звітів про помилки очима - саме так знаходять «а тут, виявляється, повний JSON запиту з паролем».

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

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

Інші рівні
Junior 35 Senior 31

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