Безпека: питання на співбесіді рівня 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 не вразливі: браузер не додає такий заголовок автоматично.
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 зазвичай не отримає).
Таймаути й ліміт розміру відповіді - теж обов'язкові, інакше функцію можна використати для навантаження на сервер.
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» означає «замінити сутності їхнім вмістом», тобто ввімкнути підстановку. Його часто додають «щоб працювали &-подібні сутності» - і відкривають 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.
У 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, jQueryextend); Object.freeze(Object.prototype)- радикальний захист, але може зламати сторонні бібліотеки.
Чим це стосується PHP-розробника: фронтенд Laravel-застосунку (Vue, React, Alpine) і Node-скрипти збирання/SSR - теж частина поверхні атаки; оновлення npm-залежностей важливе так само, як composer.
Сесія: сервер зберігає стан (в базі, 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, не класти в токен секретних даних.
Фіксація сесії (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, видаленням облікового запису.
Скидання пароля - «чорний хід» в обліковий запис. Якщо він слабший за вхід, атакують саме його.
Вимоги до токена скидання:
- випадковий і довгий - криптографічно стійкий генератор (
random_bytes,Str::random()), не похідний від email, часу чи id; - одноразовий - після використання недійсний;
- короткий термін дії - хвилини чи година;
- у базі - хеш токена, а не сам токен: витік бази не дає активних посилань для скидання;
- прив'язаний до облікового запису - токен одного користувача не працює для іншого.
Процес:
- користувач вводить email - відповідь однакова незалежно від того, чи існує обліковий запис («Якщо обліковий запис існує, ми надіслали лист»);
- лист із посиланням, що містить токен; відправка - через чергу (щоб час відповіді не видавав існування облікового запису);
- перехід за посиланням - форма нового пароля; токен перевіряється порівнянням хешів у постійному часі;
- новий пароль - з тими самими правилами, що й при реєстрації (довжина, перевірка у базі витоків);
- після зміни: токен знищено, усі інші сесії й токени 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.
Passkey - облікові дані на основі криптографії з відкритим ключем (стандарт WebAuthn/FIDO2) замість пароля.
Як це працює:
- реєстрація: пристрій користувача (телефон, ноутбук, апаратний ключ) створює пару ключів для конкретного сайту. Приватний ключ лишається на пристрої (в захищеному сховищі), сайт отримує й зберігає лише публічний;
- вхід: сервер надсилає випадковий виклик (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) уже пропонують вхід без пароля.
«Запам'ятати мене» - користувач лишається автентифікованим тижні й місяці, навіть після закриття браузера, коли звичайна сесія вже завершилася.
Як це роблять правильно:
- окремий довгоживучий токен у cookie - випадковий, криптографічно стійкий, а не email, id чи хеш пароля;
- у базі - хеш токена (чи токен, прив'язаний до користувача, як у Laravel -
remember_token), щоб витік бази не давав готових cookie; - cookie з
HttpOnly,Secure,SameSite- недоступна JavaScript і не передається по HTTP; - при використанні токен створює нову звичайну сесію;
- при виході токен знищується на сервері, а не лише стирається cookie в браузері.
Ризики довгих сесій і що з ними робити:
- викрадена cookie діє довго. Обмежити абсолютний термін (наприклад, 30 днів), а для чутливих дій вимагати повторного підтвердження паролем (зміна email, пароля, MFA, платіжних даних) - так працює
password.confirmmiddleware в Laravel; - вихід «з усіх пристроїв» має справді анулювати remember-токени. У Laravel зміна
remember_token(наприклад, при зміні пароля чиAuth::logoutOtherDevices()) робить старі cookie недійсними; - «ротація» токена при кожному використанні з виявленням повторного використання - так помічають, що cookie скопійовано;
- список активних сесій у налаштуваннях облікового запису з пристроєм, місцем і часом останньої активності - користувач бачить чужий вхід і може його завершити (драйвер сесій
databaseдає таку можливість).
Таймаути сесій (рекомендації OWASP):
- неактивність - сесія завершується після N хвилин без дій (для банківських застосунків - хвилини, для звичайних сайтів - години);
- абсолютний - навіть активна сесія закінчується після певного часу;
- для адмінок і фінансових операцій - коротші терміни, ніж для звичайних користувачів.
Регенерація ідентифікатора сесії після входу, зміни прав і виходу - захист від фіксації сесії ($request->session()->regenerate()).
Компроміс: «Запам'ятати мене» - зручність коштом безпеки. Для публічних і спільних комп'ютерів його не варто вмикати за замовчуванням; для застосунків з чутливими даними варто обмежити або вимагати MFA при відновленні сесії з remember-токена.
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 шифрує лише новим ключем, а розшифровує - пробуючи поточний і попередні. Користувачі не розлогінюються, старі посилання діють.
Повна ротація (наприклад, після витоку ключа):
- новий ключ у
APP_KEY, старий - уAPP_PREVIOUS_KEYS; - перешифрувати дані в базі новим ключем (команда, що читає й зберігає кожне зашифроване поле);
- через час, достатній для завершення сесій і закінчення терміну посилань, прибрати старий ключ з
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 і ротувався незалежно.
Не шифрувати те, що має бути хешованим: паролі й одноразові токени - хеш (їх не потрібно розшифровувати), а шифрування - для даних, які застосунку треба прочитати у відкритому вигляді.
Підписаний 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(): підписує вже сховище, і файл віддається без участі застосунку.
Два типи файлів - два способи зберігання:
- публічні (аватари, зображення статей) - диск
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' - свідоме рішення. Бакет без публічного доступу на рівні налаштувань провайдера - додатковий захист від помилки в коді.
Видалення: при видаленні запису видаляти й файл (і навпаки - прибирати «осиротілі» файли), інакше дані, які користувач вважає видаленими, лишаються доступними.
Чутливі дані часто витікають не через злам бази, а через допоміжні системи: логи, звіти про помилки, інструменти налагодження, сторонні сервіси моніторингу.
Де вони з'являються:
- стек винятку містить аргументи функцій - пароль, переданий у
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 запиту з паролем».
Питання рівня Middle з реальних технічних співбесід - 37 питань у 7 темах, розібраних із відповідями. Нижче - теми цього рівня та сусідні рівні, якщо хочете звузити або розширити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 78 відкритих вакансій рівня Middle. Переглянути вакансії