Senior: питання на співбесіді з теми «Захист і інтеграції»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
5 питань
Ендпоінт вебхука публічний: будь-хто, хто знає адресу, може надіслати на нього «платіж підтверджено». Тому кожен запит треба перевіряти.
1. Підпис HMAC. Відправник обчислює HMAC від тіла запиту спільним секретом і передає в заголовку. Отримувач рахує те саме й порівнює:
$expected = 'sha256=' . hash_hmac('sha256', $request->getContent(), config('services.github.webhook_secret'));
abort_unless(hash_equals($expected, (string) $request->header('X-Hub-Signature-256')), 403);
- Підписується сире тіло запиту, байт у байт - не розібраний і знову серіалізований JSON.
- Порівняння - через
hash_equals, за сталий час.
2. Мітка часу проти повторів. Перехоплений правильний запит можна надіслати ще раз. Тому відправник включає в підпис мітку часу (як Stripe: t=...,v1=...), а отримувач відкидає запити, старші за кілька хвилин.
3. Ідемпотентна обробка. Відправники доставляють вебхуки «щонайменше раз» і повторюють їх при помилках чи таймаутах. Кожна подія має ID - зберігайте оброблені ID і пропускайте дублікати.
4. Швидка відповідь, обробка в черзі. Перевірити підпис, зберегти подію, поставити завдання в чергу й одразу відповісти 2xx. Довга обробка в самому запиті призводить до таймаутів і повторних доставок.
5. Не довіряти вмісту сліпо. Для критичних подій (оплата) безпечно перезапитати стан через API відправника: «чи справді платіж pi_123 успішний?».
Додатково: HTTPS обов'язковий; білий список IP відправника - лише як доповнення, не замість підпису; ротація секрету з періодом, коли приймаються обидва.
Докладніше в документації: Перевірка доставок вебхуків GitHub
Cache-Control каже клієнтам і проміжним кешам (CDN, проксі), чи можна зберігати відповідь і як довго:
Cache-Control: public, max-age=300 # будь-хто може кешувати 5 хвилин
Cache-Control: private, max-age=60 # лише браузер користувача
Cache-Control: no-store # не зберігати взагалі (персональні, чутливі дані)
Cache-Control: no-cache # зберігати, але перевіряти перед кожним використанням
Умовні запити й ETag. Сервер віддає «відбиток» версії ресурсу:
HTTP/1.1 200 OK
ETag: "a1b2c3"
Cache-Control: no-cache
Наступного разу клієнт питає «чи змінилося?»:
GET /products/42
If-None-Match: "a1b2c3"
Якщо ні - 304 Not Modified без тіла. Економиться трафік і серіалізація, хоча сам запит до сервера відбувається. Аналог за датою - Last-Modified / If-Modified-Since.
ETag для конкурентних змін: If-Match: "a1b2c3" при PUT/PATCH - оновити лише якщо ресурс не змінився з того часу, як клієнт його прочитав. Інакше 412 Precondition Failed. Це оптимістичне блокування на рівні HTTP, захист від загублених оновлень.
Нюанси:
- Персональні відповіді не можна позначати
public: CDN віддасть дані одного користувача іншому. Для відповідей, що залежать від токена, -privateабоno-store. Varyкаже кешу, від яких заголовків запиту залежить відповідь:Vary: Accept-Language,Vary: Authorization.- ETag має бути дешевим: якщо для нього треба зібрати всю відповідь, економиться лише трафік. Краще брати версію з бази (
updated_at, лічильник версії). - Інвалідація - найскладніше: короткий
max-ageплюсstale-while-revalidateчасто практичніші за спроби точно скидати кеш.
SSRF (Server-Side Request Forgery) - зловмисник змушує ваш сервер зробити запит туди, куди йому потрібно. Типові функції-мішені: імпорт за URL, попередній перегляд посилань, вебхуки (користувач вказує URL для сповіщень), завантаження аватара за посиланням, генерація PDF з HTML.
// вразливо
$image = Http::get($request->input('avatar_url'))->body();
Що можна дістати через ваш сервер:
- сервіси метаданих хмари:
http://169.254.169.254/- тимчасові облікові дані AWS/GCP/Azure. Класичний сценарій масштабних витоків; - внутрішні сервіси, недоступні ззовні: адмінки, бази з HTTP-інтерфейсом, Redis, панелі моніторингу,
localhost:8080; - сканування внутрішньої мережі за часом відповіді;
- файли через схеми
file://,gopher://- залежно від HTTP-клієнта.
Захист (кілька шарів, бо кожен окремо обходиться):
1. Дозволений список, а не заборонений. Якщо можна - лише відомі домени (інтеграції з конкретними сервісами).
2. Схема й порт: лише https (за потреби http), стандартні порти.
3. Перевірка IP після розв'язання DNS: заборонити приватні діапазони (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), 127.0.0.0/8, link-local 169.254.0.0/16, IPv6-аналоги (::1, fc00::/7, fe80::/10).
Пастки, через які наївна перевірка не працює:
- DNS rebinding: домен при перевірці дає публічну адресу, а при реальному запиті - внутрішню. Перевіряти треба той IP, до якого реально підключаєтесь (зафіксувати розв'язану адресу для з'єднання);
- редиректи: перевірений URL повертає
302наhttp://169.254.169.254/. Редиректи вимкнути або перевіряти кожен крок; - альтернативні записи IP:
http://2130706433/,http://0x7f.1/,http://[::ffff:127.0.0.1]/- усе це127.0.0.1.
4. Мережева ізоляція: вихідні запити до користувацьких URL - через окремий проксі чи сервіс без доступу до внутрішньої мережі й метаданих. Найнадійніший шар.
5. Хмара: IMDSv2 на AWS (сесійний токен для метаданих) робить класичну атаку значно складнішою.
6. Обмеження відповіді: тайм-аути, максимальний розмір, перевірка типу вмісту - і не повертати клієнту сирі відповіді чи детальні помилки підключення (це дає зловмиснику «сліпе» сканування).
Для вебхуків з URL від користувача - ті самі правила плюс перевірка URL при збереженні й при кожній відправці.
Навіщо два різні токени:
- токен доступу - короткоживучий (хвилини), надсилається з кожним запитом. Якщо його вкрадуть - шкода обмежена часом;
- refresh-токен - довгоживучий (дні, тижні), використовується лише для отримання нового токена доступу і лише на ендпойнті авторизації.
Це компроміс між безпекою (короткі токени доступу) і зручністю (користувач не вводить пароль щогодини).
Чому refresh-токен - найцінніша мішень: хто його має, той може безстроково отримувати нові токени доступу. Тому RFC 9700 (найкращі практики безпеки OAuth 2.0) вимагає для публічних клієнтів (SPA, мобільні застосунки) або прив'язки токена до клієнта (DPoP, mTLS), або ротації.
Ротація refresh-токенів: кожне оновлення видає новий refresh-токен, а старий стає недійсним.
refresh_1 → (access_2, refresh_2) refresh_1 недійсний
refresh_2 → (access_3, refresh_3) refresh_2 недійсний
Виявлення повторного використання - головна перевага ротації:
- зловмисник викрав
refresh_2і скористався ним першим - отримавrefresh_3; - легітимний клієнт пізніше пред'являє
refresh_2- вже використаний; - сервер розуміє, що один з двох - зловмисник, і відкликає всю сім'ю токенів (усі, що походять від початкового входу). Обидва мусять увійти заново, а зловмисник втрачає доступ.
Для цього сервер зберігає ланцюжок: кожен refresh-токен знає свою «сім'ю» і чи був використаний.
Практичні деталі:
- паралельні запити: дві вкладки одночасно оновлюють токен з тим самим refresh-токеном - друга виглядає як «повторне використання». Рішення - короткий період допуску для того самого токена або серіалізація оновлення на клієнті;
- абсолютний термін життя сесії: навіть з ротацією ланцюжок не повинен жити вічно (наприклад, 30-90 днів до повторного входу);
- відкликання при зміні пароля і виході - усіх refresh-токенів користувача;
- зберігання: хеш у базі (як паролі); на клієнті - захищене сховище ОС для мобільних,
HttpOnly-cookie для вебу.
Для браузерних SPA взагалі варто зважити, чи потрібні токени: сесійна cookie з бекендом на тому ж домені (Sanctum SPA) чи патерн BFF (бекенд для фронтенду, що тримає токени на сервері) прибирають токени з браузера.
У Laravel Passport refresh-токени є з коробки; терміни задаються Passport::tokensExpireIn() і Passport::refreshTokensExpireIn().
Докладніше в документації: RFC 9700: найкращі практики безпеки OAuth 2.0
Коли ваш сервіс викликає інший (мікросервіс, партнерський API, внутрішній воркер), потрібно довести, який сервіс робить запит, і що запит не змінено по дорозі.
1. Статичний API-ключ / спільний секрет у заголовку - найпростіше:
Authorization: Bearer svc_billing_8f2a...
Мінуси: довгоживучий секрет, що зберігається в кількох місцях; викрадений ключ працює звідусіль; ротація болісна. Прийнятно для простих інтеграцій із ротацією й обмеженням за IP.
2. Підпис запиту (HMAC) - секрет не передається мережею, передається підпис:
X-Timestamp: 1760000000
X-Signature: hex(HMAC-SHA256(secret, method + path + timestamp + sha256(body)))
Отримувач обчислює підпис сам і порівнює у постійному часі (hash_equals). Мітка часу з коротким вікном (кілька хвилин) захищає від повторного відтворення. Так підписують вебхуки (Stripe, GitHub) і запити AWS (SigV4).
3. Взаємний TLS (mTLS) - обидві сторони пред'являють сертифікати:
- сервер перевіряє клієнтський сертифікат, виданий вашим внутрішнім центром сертифікації;
- ідентичність сервісу - у сертифікаті, а не в секреті в коді;
- RFC 8705 описує прив'язку OAuth-токенів до сертифіката: викрадений токен без приватного ключа клієнта непридатний.
Мінус - інфраструктура: власний CA, видача й ротація сертифікатів. Service mesh (Istio, Linkerd) роблять mTLS прозорим для застосунку.
4. Короткоживучі токени від центрального сервісу ідентифікації - OAuth 2.0 Client Credentials:
POST /oauth/token
grant_type=client_credentials&client_id=billing&client_secret=...&scope=orders:read
Сервіс отримує токен на хвилини з конкретними правами (scopes), отримувач перевіряє підпис і aud. У хмарах - ідентичності робочих навантажень (IAM-ролі, workload identity) без статичних секретів узагалі.
Як обрати:
- дві-три внутрішні інтеграції - підписані запити з ротацією секретів;
- багато сервісів, потрібні права й аудит - OAuth client credentials (у Laravel - Passport);
- мережа з нульовою довірою, високі вимоги - mTLS, часто разом із токенами.
Обов'язкове в будь-якому варіанті: найменші права для кожного сервісу, ротація секретів без простою (два дійсні ключі на час переходу), журнал викликів.