Веб-безпека: автентифікація, сесії й секрети
20 питань · ~20 хв · Версія v3.0
Увійдіть, щоб продовжити
Паролі й хешування, багатофакторна автентифікація, сесії й cookie, JWT, авторизація, заголовки безпеки, секрети й ланцюжок постачання - питання від middle до lead.
- За спробу
- 20
- У пулі
- 62
- Проходжень
- 0
- Середній бал
- -
- Пройшли на 70%+
- -
Питання для підготовки
47 питаньПаролі ніколи не зберігають у відкритому вигляді чи зашифрованими - лише як хеш спеціальної повільної функції з сіллю.
Чому не шифрування: зашифроване можна розшифрувати. Хто отримав ключ разом з базою, отримав усі паролі. Хеш - односторонній: навіть сервер не знає пароля, лише вміє перевірити.
Чому не md5/sha256: вони створені швидкими. Сучасна відеокарта перебирає мільярди хешів SHA-256 за секунду, тож витеклу базу з простими паролями підбирають за години.
Правильні функції - навмисно повільні й налаштовувані:
- Argon2id - рекомендований вибір;
- bcrypt - перевірений, широко підтримуваний (обмеження: використовує лише перші 72 байти пароля).
$hash = password_hash($password, PASSWORD_ARGON2ID); // або PASSWORD_DEFAULT (bcrypt)
if (password_verify($input, $hash)) {
// вхід
}
Hash::make($password); // Laravel: bcrypt за замовчуванням, налаштовується
Hash::check($input, $hash);
Сіль - випадкове значення, унікальне для кожного пароля. Вона робить однакові паролі різними хешами й унеможливлює готові таблиці (rainbow tables). password_hash генерує сіль сам і зберігає її в рядку хешу - окрема колонка не потрібна.
Ще правила:
- Перехешування при вході (
password_needs_rehash, у Laravel - автоматично), коли збільшується вартість чи змінюється алгоритм. - Ліміт спроб входу, щоб онлайн-перебір був непрактичним.
- Перевірка на злиті паролі (Have I Been Pwned, правило
Password::uncompromised()у Laravel) замість вимог «велика літера + цифра + символ».
Перелік облікових записів - можливість дізнатися, чи зареєстрована певна адреса email (чи логін) у системі. Сама по собі це не злам, але:
- полегшує атаки: зловмисник перебирає паролі лише для існуючих облікових записів, а фішинг цілить у справжніх клієнтів;
- розкриває приватну інформацію: сам факт облікового запису на медичному, юридичному чи дейтинговому сервісі - чутливі дані.
Де це протікає:
1. Повідомлення входу:
«Користувача з таким email не знайдено» ← протікає
«Невірний пароль» ← протікає
«Невірний email або пароль» ← правильно
2. Скидання пароля:
«Лист надіслано» / «Такого email немає» ← протікає
«Якщо обліковий запис існує, ми надіслали інструкції на email» ← правильно
3. Реєстрація: «Email уже зайнятий» - складніше уникнути, бо користувачу треба знати, що він уже зареєстрований. Варіант - завжди відповідати «Перевірте пошту», а існуючому користувачу надіслати лист «Ви вже маєте обліковий запис, ось посилання на вхід».
4. Час відповіді. Навіть з однаковими повідомленнями: якщо для неіснуючого користувача сервер відповідає за 5 мс, а для існуючого - 300 мс (бо перевіряє хеш bcrypt), час видає правду. Захист - виконувати перевірку хешу завжди (з фіктивним хешем, якщо користувача немає) і відправляти листи асинхронно з черги, щоб час відповіді на скидання пароля не залежав від того, чи надсилається лист.
5. API й публічні профілі: GET /api/users/check-email?email=... для «живої» перевірки в формі, профілі за передбачуваними URL.
Компроміс зручності й безпеки. Повністю прибрати перелік часто неможливо без погіршення UX (реєстрація, вхід через телефон). Тоді важливо обмежити частоту запитів, що його дозволяють, - щоб перевірка тисяч адрес займала роки.
У Laravel: стандартний ValidationException з auth.failed дає загальне повідомлення, а Password::sendResetLink повертає статус, який варто показувати однаково для всіх випадків. Fortify за замовчуванням дотримується цих правил, але власні контролери варто перевірити.
Докладніше в документації: OWASP: шпаргалка з автентифікації
Підписаний 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(): підписує вже сховище, і файл віддається без участі застосунку.
IDOR (Insecure Direct Object Reference) - застосунок використовує ідентифікатор від клієнта, щоб знайти об'єкт, і не перевіряє, чи має користувач до нього доступ. Ендпойнти з {id} в адресі зазвичай перевіряють. Вразливості частіше ховаються в менш помітних місцях.
1. Ідентифікатори в тілі запиту й прихованих полях:
<input type="hidden" name="account_id" value="15">
Transfer::create(['from_account_id' => $request->account_id, ...]); // рахунок не перевірено
Прихований чи disabled у формі - не захищений: його змінюють у DevTools.
2. Завантаження й перегляд файлів: /download?file=invoices/1041.pdf, /attachments/88/preview, прямі посилання на файли в публічному сховищі.
3. Експорт і звіти: /export?user_ids[]=5&user_ids[]=6 - перевіряють права на експорт загалом, але не на кожен переданий ідентифікатор.
4. Масові дії: «видалити обрані» отримує масив id - перевірка лише першого чи жодного.
5. Фільтри списків: /orders?customer_id=7 - список фільтрується за параметром, а не обмежується правами користувача.
6. Пов'язані об'єкти при створенні: POST /comments {"post_id": 42} - коментар до приватного поста, до якого немає доступу; {"team_id": 9} - додати себе в чужу команду.
7. Livewire й інші компоненти зі станом: ідентифікатор у публічній властивості, який змінюють у браузері (без #[Locked] чи моделі у властивості).
8. GraphQL і вкладені поля: доступ до об'єкта перевірено, а до вкладених зв'язків (user { orders { ... } }) - ні.
9. Адмінки й службові панелі: перевірено, що користувач - адміністратор, але не що він адміністратор цієї організації.
Як закрити системно:
- шукати через власника чи область видимості:
$user->accounts()->findOrFail($id),$team->members()->...; - валідація з умовою власності:
Rule::exists('accounts', 'id')->where('user_id', $user->id); - політика для кожного об'єкта, отриманого з ідентифікатора, - включно з масивами (
foreach ($ids ...)чи один запит з умовою власності); - глобальні області видимості для багатоорендних даних - щоб чужі записи не потрапляли в запити за замовчуванням.
Непередбачувані ідентифікатори (UUID) ускладнюють перебір, але не замінюють перевірку: ідентифікатори «протікають» через URL, листи й логи.
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 не вразливі: браузер не додає такий заголовок автоматично.
Прочитати - ще не значить знати
20 питань, по одному на екран, ~20 хв. Після завершення - розбір кожної помилки з посиланням на питання.