Авторизація в API: ролі, доступи, негативні сценарії
Зміст
Найдорожчі баги в API — не там, де сервіс упав з 500, а там, де він спокійно віддав 200 OK з чужими даними. Ендпоінт перевірив, що ти залогінений, і забув перевірити, що конкретне замовлення чи проєкт належить саме тобі. Зовні все зелене: статус правильний, тіло валідне за схемою, тест радіє. А насправді будь-який користувач читає рахунки будь-якого іншого, змінивши одну цифру в URL. За рейтингом OWASP API Security Top 10 (2023) саме такий клас — Broken Object Level Authorization — стоїть на першому місці серед ризиків API. Тому авторизацію не можна тестувати «на позитиві»: її перевіряють насамперед тим, що заборонене — заборонене.
Ця глава — канонічний виклад функціональних перевірок доступів на сайті: різниця 401/403, матриця ролей, перебір id, негативні сценарії токена. Механіку самих токенів (як влаштований JWT, як працює OAuth, refresh проти access) тут не переказуємо — вона розібрана у web-главі про автентифікацію та авторизацію; структура тіла помилки й коди 4xx — у главі «Негативні перевірки й обробка помилок». Обидві варто мати за плечима: далі спираємось на JWT, Bearer і refresh без повторних пояснень. Тема — одна з найчастіших на інтерв'ю рівня middle+, тож перед співбесідою ця глава обов'язкова.
401 проти 403: діагностика доступу
Два коди, які плутають щодня, хоча вони відповідають на різні питання.
401 Unauthorized— «я тебе не впізнав». Проблема автентифікації: токен відсутній, протермінований, зіпсований, схема заголовка неправильна. Сервер не знає, хто ти, тому й не береться вирішувати, що тобі можна. За RFC 9110 відповідь401мусить нести заголовокWWW-Authenticateіз викликом (challenge).403 Forbidden— «я знаю, хто ти, але сюди не можна». Проблема авторизації: користувач автентифікований, але не має прав на цю дію. Читач намагається видалити проєкт; менеджер лізе в чужий відділ.
Порядок незмінний: спершу автентифікація (401), потім авторизація (403). Для тестувальника це готовий діагностичний компас: 401 — копай у бік логіну й токена; 403 — копай у бік ролей і прав.
Є третій, підступний варіант. Деякі API навмисно повертають 404 Not Found замість 403 на чужий або недозволений ресурс — щоб не підтверджувати сам факт його існування. З погляду безпеки це свідоме рішення (не витікає, що об'єкт з таким id узагалі є). З погляду тесту це означає: перевіряти треба не лише «є доступ / немає доступу», а й який саме код сервіс віддає за домовленістю. Плутанина тут — не косметика, а реальний баг: 500 замість 403 виказує, що сервер не обробив ситуацію, а 200 замість 403 — що не обробив її катастрофічно.
| Код | Що означає | Типова причина |
|---|---|---|
401 | Не автентифікований | Немає/протух/зіпсований токен, неправильна схема |
403 | Автентифікований, але без прав | Роль не дозволяє операцію; чужий ресурс |
404 (як 403) | Свідомо приховане існування ресурсу | Політика «не підтверджувати наявність» |
500 на невалідному вході | Баг обробки | Сервер не передбачив негативний сценарій |
Матриця «ролі × ресурси × операції»
Вичерпно перебрати всі доступи неможливо — це комбінаторний вибух. Тому покриття будують як матрицю з трьох осей, де кожна клітинка — очікуваний результат:
- Ролі — від найбіднішої до найбагатшої на права: анонім, звичайний користувач, редактор, адміністратор, а окремо — користувач іншого власника/тенанта (той самий рівень прав, але чужі дані).
- Ресурси — власні проти чужих; публічні проти приватних.
- Операції — читання, список, створення, зміна, видалення (CRUD плюс list).
Клітинка каже, що має статися: дозволено (2xx), заборонено (403), неавтентифіковано (401) чи приховано (404). Найцінніші клітинки — негативні: не «адмін може видалити», а «редактор НЕ може видалити», «чужий користувач НЕ бачить мій проєкт». Позитивні клітинки зазвичай проходять самі собою — фічу ж робили саме під них. Дірки живуть у негативних, бо їх легко забути й на етапі розробки, і на етапі тесту.
| Операція \ Роль | Анонім | Власник ресурсу | Чужий користувач | Адмін |
|---|---|---|---|---|
GET /projects/1 | 401 | 200 | 403/404 | 200 |
PUT /projects/1 | 401 | 200 | 403/404 | 200 |
DELETE /projects/1 | 401 | 403 | 403/404 | 204 |
Два вектори варто назвати окремо, бо термінологію питають на співбесіді. Коли роль тягнеться до операції вищого рівня прав (користувач викликає адмінський ендпоінт) — це вертикальний вектор; коли роль лізе до чужого ресурсу свого ж рівня — горизонтальний. Атакувальний кут і формальні означення ескалацій — тема розділу про безпеку (функціонально нам достатньо різниці «вгору по правах» проти «вбік до чужого»).
Матриця природно лягає в data-driven тест: одна таблиця клітинок, один прогін.
// Матриця доступу як параметризований тест
const matrix = [
{ role: 'anon', method: 'GET', path: '/api/projects/1', expected: 401 },
{ role: 'viewer', method: 'GET', path: '/api/projects/1', expected: 200 },
{ role: 'viewer', method: 'DELETE', path: '/api/projects/1', expected: 403 },
{ role: 'admin', method: 'DELETE', path: '/api/projects/1', expected: 204 },
];
for (const cell of matrix) {
test(`${cell.role} ${cell.method} ${cell.path} -> ${cell.expected}`, async () => {
const api = clientFor(cell.role); // окремий контекст на роль
const res = await api.fetch(cell.path, { method: cell.method });
expect(res.status()).toBe(cell.expected);
});
}
Побудову таких клієнтів і фікстур детальніше розбирає глава «API-автотести в коді»; техніки, за якими обирають самі клітинки (класи, межі), — «Тест-дизайн для API».
BOLA/IDOR: доступ до чужих даних через перебір id
Найпоширеніший критичний баг рівня API має два імені. IDOR (Insecure Direct Object Reference) — старіший, ширший термін: застосунок пускає до об'єкта за прямим ідентифікатором, не перевіривши прав на цей конкретний об'єкт. BOLA (Broken Object Level Authorization) — та сама вада мовою OWASP API Security Top 10, де вона очолює список.
Механізм тривіальний і саме тому небезпечний. Ендпоінт /api/orders/1024 перевіряє автентифікацію (токен валідний — ти залогінений) і одразу віддає замовлення, не звіривши, що воно твоє. Міняєш id на 1025 — і читаєш чуже. Послідовні числові id роблять перебір справою кількох секунд.
Ключове місце — гейт власності (object-level check), якого на діаграмі бракує. Ось де в конвеєрі авторизації живе BOLA — а поверхом вище, на гейті ролей, ловиться вертикальний вектор (Broken Function Level Authorization, BFLA):
Функціональна перевірка проста й нещадна: створи ресурс під користувачем A, запам'ятай його id, потім під користувачем B спробуй той самий id прочитати, змінити й видалити. Кожна спроба має дати 403 або 404 — ніколи 200.
// A створює ресурс; B пробує його прочитати й видалити
const created = await apiUserA.post('/api/orders', { data: { item: 'X' } });
const orderId = (await created.json()).id;
const read = await apiUserB.get(`/api/orders/${orderId}`);
expect([403, 404], 'B не має бачити замовлення A').toContain(read.status());
const del = await apiUserB.delete(`/api/orders/${orderId}`);
expect([403, 404], 'B не має видаляти замовлення A').toContain(del.status());
Дві поширені хиби навколо цього. Перша: заміна числових id на UUID нібито «закриває» BOLA. Ні — випадковий id лише ускладнює перебір, але діру не латає: якщо перевірки власності немає, той, хто якось дізнався чужий UUID (з логів, реферера, спільного посилання), однаково дістанеться даних. UUID — це замок на дверях, а не стіна. Друга: перевіряти лише читання. Зміна й видалення чужого об'єкта — так само BOLA, і часто небезпечніша.
Поруч стоїть BOPLA (Broken Object Property Level Authorization) — коли доступ до об'єкта є, але не до окремих його полів: користувач надсилає в тілі PATCH поле role: "admin" або isVerified: true, а сервер його бездумно застосовує (класичне mass assignment). Функціонально це перевіряють так само — спробувати записати поле, якого роль не повинна чіпати, і переконатися, що воно проігноровано або дало 403.
Негативні сценарії токена: протермінований, невалідний, відкликаний
Токен може стати недійсним трьома різними способами, і їх легко злити в один «поганий токен», хоча поводяться вони по-різному.
- Протермінований (expired). Час
expу минулому. Очікування —401. Найлегший сценарій відтворити: узяти токен із давно минулимexp(payload JWT читається без бібліотек — деталі у web-главі). - Невалідний (invalid). Зіпсований підпис, підмінений payload, покручений base64, неправильна схема. Сюди ж — димова перевірка на атаку
alg: none(токен без підпису з алгоритмом «none»): грамотний сервіс мусить її відхилити401, а не прийняти. Головне правило: будь-яке сміття в токені має давати401, ніколи500— падіння на невалідному вході саме по собі є знахідкою. - Відкликаний (revoked). Найтонший. Користувач вийшов, змінив пароль, його заблокував адмін — а токен ще не протермінувався. Ось де видно різницю архітектур: сесія на сервері відкликається миттєво (видалили запис), а stateless-JWT лишається валідним до
exp, поки немає денойлиста (denylist) чи короткого TTL. Тому результат перевірки «відкликаний токен усе ще працює» треба читати уважно: для чистого JWT без відкликання це очікувано; але якщо продукт обіцяє миттєвий вихід із усіх сесій, а старий токен живе — це реальний баг, який слід підняти, а не замовчати.
| Сценарій | Що надсилаємо | Очікуваний код |
|---|---|---|
Немає заголовка Authorization | нічого | 401 |
Схема без слова Bearer / зайвий пробіл | покручений заголовок | 401 |
| Протермінований токен | валідний підпис, минулий exp | 401 |
Зіпсований підпис / alg: none | підмінений токен | 401 (не 500) |
| Токен іншого тенанта/аудиторії | чужий валідний токен | 401/403 |
| Відкликаний токен | валідний, але скасований | 401 (якщо є відкликання) |
// Негативні сценарії токена одним параметризованим прогоном
const badTokens = [
{ name: 'протермінований', token: EXPIRED_JWT },
{ name: 'зіпсований підпис', token: TAMPERED_JWT },
{ name: 'alg:none', token: ALG_NONE_JWT },
{ name: 'порожній', token: '' },
];
for (const t of badTokens) {
test(`${t.name} -> 401`, async ({ request }) => {
const res = await request.get('/api/me', {
headers: { Authorization: `Bearer ${t.token}` },
});
expect(res.status(), 'невалідний токен не має валити сервер').toBe(401);
});
}
Токени в автопрогонах
Коли перевірок доступу багато, автентифікація стає окремою інженерною задачею — і окремим джерелом флаку (flakiness).
Одна автентифікація на роль, а не на тест. Логінитися формою в кожному тесті повільно й крихко; замість цього стан здобувають один раз через API й тримають окремий клієнт (контекст) на кожну роль. Тоді матриця доступу просто вибирає потрібний клієнт.
// Один контекст на роль, автентифікація один раз
function clientFor(role: Role) {
return request.newContext({
baseURL: BASE_URL,
extraHTTPHeaders: { Authorization: `Bearer ${tokenFor(role)}` },
});
}
Протермінування посеред прогону. Класична нічна поломка: токен, згенерований на старті, дожив своє в середині довгої сюїти, і половина тестів сипле 401. Лікування — централізувати токен (не копіювати по тестах), перевіряти exp наперед або ловити 401 і тихо оновлювати через refresh. Детальні стратегії з прикладом refresh-на-401 розібрані у web-главі про автентифікацію; тут головне — не хардкодити токен у фікстурах.
Ізоляція даних між акаунтами. Щоб негативні перевірки BOLA були чесними, користувачі A і B мають бути справді різними власниками (а ще краще — різними тенантами). Стратегію ізоляції стану розбирає глава «Тестові дані та стан в API-тестах».
Мінімум прав для самих креденшалів тесту. Токен, яким тест ходить у систему, не повинен бути ширшим, ніж потрібно сценарію. Інакше легко отримати оманливо зелений результат: під адмінським чи проєктним API-ключем усе дозволено, і перевірка прав фактично не виконується. Гігієна секретів (де зберігати ці токени, як не злити в репозиторій) — окрема наскрізна тема розділу про CI/CD.
Типові помилки
- Виглядає як «доступ заборонено правильно», а насправді перевірили лише код. Тест бачить
403і радіє — але тіло відповіді містить чутливі поля, які просочилися ще до відмови. Перевіряй і статус, і тіло. - Виглядає як повне покриття доступів, а насправді все ганяли під адміном. Адмін може все, тож негативні клітинки під ним завжди «зелені». Без негативного актора (звичайний користувач, чужий власник) перевірки прав не існує.
- Виглядає як
200 OK— усе гаразд, а насправді це критичний баг. Для чужого ресурсу успішний код — не перемога, а витік. Оракул тут інвертований: очікуємо відмову. - Виглядає як «UUID захистив від перебору», а насправді дірку не закрито. Випадковий id ускладнює вгадування, але не замінює перевірку власності. Security by obscurity — не безпека.
- Виглядає як баг тесту («відкликаний токен ще працює»), а насправді це може бути баг дизайну. Для stateless-JWT без денойлиста це очікувано; але якщо продукт обіцяє миттєвий logout — це справжня знахідка. Розберись, перш ніж списати.
- Виглядає як баг прав («
403на своєму ж ресурсі»), а насправді неправильний акаунт у фікстурі. Часто винен не продукт, а тест: ресурс створено під одним користувачем, а запит іде під іншим, або загубився tenant-scope.
Підсумок
401— «не впізнав» (автентифікація),403— «впізнав, але не можна» (авторизація); інколи404навмисно підміняє403, щоб приховати існування ресурсу.- Покриття доступів — це матриця «роль × ресурс × операція», і найцінніші клітинки в ній негативні: те, що роль зробити НЕ може.
- BOLA/IDOR перевіряють доступом до чужого об'єкта під іншим користувачем — для читання, зміни й видалення;
200на чуже є критичним багом, а UUID його не лікує. - Токен стає недійсним трьома шляхами (протух / зіпсований / відкликаний), і кожен має давати
401, ніколи500; відкликання читай з огляду на архітектуру (сесія проти stateless-JWT). - У прогонах автентифікацію централізуй за ролями, тестові креденшали тримай на мінімумі прав — актор із вузькими правами ловить те, що адмін пропускає.
Що питають на співбесіді
- «Чим
401відрізняється від403?» — базовий фільтр: перевіряють, чи не плутаєш автентифікацію з авторизацією. Сильна відповідь додає випадок404-замість-403і згадку, що401тягнеWWW-Authenticate. - «Як би ти протестував доступи в API з трьома ролями?» — чекають структуру: матриця «роль × ресурс × операція», акцент на негативних клітинках, data-driven прогін, а не перелік ручних кейсів.
- «Що таке IDOR / BOLA і як зловити його тестом?» — чекають механізм (перебір id без перевірки власності) і сценарій «під користувачем B чіпаю ресурс A»; плюс, що згадаєш і зміну/видалення, не лише читання.
- «Тест під адмін-токеном зелений — цього достатньо?» — пастка на негативного актора. Правильна відповідь: ні, бо адмін маскує відсутність перевірки прав.
- «Токен протух посеред прогону — що робиш?» — про централізоване зберігання, перевірку
expнаперед і refresh-на-401; інтерв'юер дивиться, чи розумієш різницю між багом продукту і крихкістю сюїти.
Джерела
- OWASP API Security Top 10 — 2023 — актуальний перелік ризиків API; ключові для цієї глави: API1 (BOLA), API3 (BOPLA), API5 (Broken Function Level Authorization).
- OWASP API1:2023 — Broken Object Level Authorization — детальний розбір BOLA з прикладами й порадами щодо перевірки.
- RFC 9110 «HTTP Semantics», §15.5.2 (401) і §15.5.4 (403) — нормативні означення кодів автентифікації й авторизації.
- MDN — 403 Forbidden — стислий опис семантики й типових причин.
- ISTQB Certified Tester Foundation Level (CTFL) v4.0 — тестування безпеки як вид нефункціонального тестування (розділ про типи тестів); поглиблено доступи й авторизацію розбирає окремий силабус ISTQB Security Tester.
Чим 401 відрізняється від 403?
401 відповідає на питання «хто ти?», 403 — «тобі можна?». 401 Unauthorized — сервер не впізнав користувача: токен відсутній, протермінований, зіпсований або схема заголовка неправильна; це проблема автентифікації, і реакція — залогінитися чи оновити токен. 403 Forbidden — сервер упізнав, але цьому користувачеві сюди не можна: звичайний юзер лізе в адмінку, менеджер — у чужий відділ; це проблема авторизації, і повторний логін нічого не змінить. Порядок незмінний: спершу автентифікація (401), потім авторизація (403) — це готовий діагностичний компас. За RFC 9110 відповідь 401 мусить нести заголовок WWW-Authenticate із викликом. Для AQA сплутати їх у тесті ролей означає збрехати асертом: там, де залогінений юзер б'ється об чужий ендпоінт, правильна відмова — 403, і очікування 401 тут неправильне.
Чому API інколи повертає 404 замість 403 на ресурс, до якого немає прав? Це баг?
Не обов'язково — частина API робить так навмисно, щоб не підтверджувати сам факт існування закритого ресурсу. Для стороннього приватний об'єкт має виглядати як неіснуючий, інакше 403 мимоволі виказує «цей id є, просто не для тебе». Тобто формально «неправильний» код може бути свідомим рішенням контракту з міркувань безпеки. Наслідок для тесту: перед асертом на код відмови треба звірятися з контрактом конкретного API, а не з підручником. Якщо контракт документує 404 на чужі ресурси — асертимо 404; якщо каже 403, а приходить 404 — це привід для розмови з командою. Небезпечніші сусіди в цьому ряду: 500 замість 403 виказує, що сервер не обробив ситуацію, а 200 замість 403 — що не обробив її катастрофічно.
Що таке матриця «роль × ресурс × операція» і чому в ній головне — негативні клітинки?
Вичерпно перебрати всі доступи неможливо — це комбінаторний вибух, тому покриття будують як матрицю з трьох осей: ролі (анонім, користувач, редактор, адмін, а окремо — чужий власник того ж рівня), ресурси (власні проти чужих, публічні проти приватних) та операції (читання, список, створення, зміна, видалення). Кожна клітинка каже, що має статися: дозволено (2xx), заборонено (403), неавтентифіковано (401) чи приховано (404). Найцінніші клітинки — негативні: не «адмін може видалити», а «редактор НЕ може видалити», «чужий користувач НЕ бачить мій проєкт». Позитивні клітинки зазвичай проходять самі собою — фічу ж робили саме під них; дірки живуть у негативних, бо їх легко забути і на етапі розробки, і на етапі тесту. Матриця природно лягає в один data-driven прогін: одна таблиця клітинок замість купи ручних кейсів.
У чому різниця вертикального й горизонтального вектора доступу?
Це два боки одного питання «а чи можна цій ролі до цього об'єкта». Вертикальний вектор — коли роль тягнеться до операції вищого рівня прав: звичайний користувач викликає адмінський ендпоінт, редактор намагається зробити те, що дозволено лише адміну. Горизонтальний — коли роль лізе до чужого ресурсу свого ж рівня: користувач читає замовлення іншого користувача, той самий обсяг прав, але не свої дані. Функціонально це різні клітинки матриці й різні негативні перевірки: вертикаль ловиться на гейті ролей (403 на адмін-операцію), горизонталь — на гейті власності об'єкта (403/404 на чужий id). Формальні означення ескалацій — тема розділу про безпеку; для функціонального тесту достатньо різниці «вгору по правах» проти «вбік до чужого».
Що таке IDOR / BOLA і як зловити його тестом?
Це найпоширеніший критичний баг рівня API під двома іменами. IDOR (Insecure Direct Object Reference) — застосунок пускає до об'єкта за прямим ідентифікатором, не перевіривши прав на цей конкретний об'єкт; BOLA (Broken Object Level Authorization) — та сама вада мовою OWASP API Security Top 10, де вона очолює список. Механізм тривіальний: ендпоінт /api/orders/1024 перевіряє автентифікацію (токен валідний) і одразу віддає замовлення, не звіривши, що воно твоє — міняєш id на 1025 і читаєш чуже. Функціональна перевірка проста й нещадна: створи ресурс під користувачем A, запам'ятай id, потім під користувачем B спробуй той самий id прочитати, змінити й видалити — кожна спроба має дати 403 або 404, ніколи 200. Оракул тут інвертований: для чужого ресурсу успішний код — не перемога, а витік.
Чи закриває заміна числових id на UUID проблему BOLA?
Ні — це поширена хиба. Випадковий UUID лише ускладнює перебір, але діру не латає: якщо перевірки власності немає, той, хто якось дізнався чужий UUID (з логів, реферера, спільного посилання, чужого експорту), однаково дістанеться даних. UUID — це замок на дверях, а не стіна; security by obscurity не є безпекою. Тому асерт «id непередбачуваний, отже, захищено» хибний: єдиний надійний захист — гейт власності на боці сервера, який перевіряє, що об'єкт належить саме цьому користувачеві. У тесті це означає, що BOLA-сценарій треба ганяти й на UUID-ендпоінтах теж — беремо реальний чужий id, отриманий під акаунтом A, і б'ємо ним під акаунтом B.
Тест під адмін-токеном зелений — цього достатньо, щоб сказати, що доступи покриті?
Ні, і це класична пастка на негативного актора. Адмін може все, тож негативні клітинки під ним завжди «зелені»: він і чуже прочитає, і адмін-ендпоінт викличе — жодна перевірка прав під ним не спрацьовує, бо порушувати нема чого. Без негативного актора — звичайного користувача, чужого власника, аноніма — перевірки прав фактично не існує, ти лише переконався, що «дозволене дозволене». Правильне покриття вимагає ролі біднішої за операцію: саме вона ловить відсутній гейт. Сюди ж лягає правило мінімуму прав для тестових креденшалів: якщо весь прогін ходить під широким адмінським чи проєктним ключем, легко отримати оманливо зелений результат, під яким прав ніхто не перевіряв.
Що таке BOPLA / mass assignment і як його перевірити?
BOPLA (Broken Object Property Level Authorization) — коли доступ до об'єкта є, але не до окремих його полів. Класика — mass assignment: користувач надсилає в тілі PATCH поле, якого чіпати не повинен (role: "admin", isVerified: true, balance), а сервер бездумно застосовує все тіло на модель. Доступ до самого об'єкта легітимний (це власний профіль), проблема саме на рівні властивостей. Функціонально перевіряється так само, як BOLA, тільки об'єкт перевірки — поле: спробувати записати атрибут, якого роль не повинна змінювати, і переконатися, що сервер його проігнорував або відповів 403, а не тихо підвищив тобі права. Важливо асертити не лише код, а й наступний стан: після відповіді перечитати об'єкт і впевнитися, що заборонене поле не змінилося.
Якими трьома шляхами токен може стати недійсним і чим вони відрізняються?
Токен може стати недійсним трьома способами, і їх легко злити в один «поганий токен», хоча поводяться вони по-різному. Протермінований (expired) — час exp у минулому; очікування — 401, найлегший сценарій відтворити. Невалідний (invalid) — зіпсований підпис, підмінений payload, покручений base64, неправильна схема заголовка або атака alg: none; будь-яке таке сміття має давати 401, ніколи 500. Відкликаний (revoked) — найтонший: користувач вийшов, змінив пароль чи його заблокував адмін, а токен ще не протермінувався. Саме на відкликанні видно різницю архітектур: серверна сесія відкликається миттєво (видалили запис), а stateless-JWT лишається валідним до exp, поки немає денойлиста чи короткого TTL. Тому кожен зі шляхів вимагає окремого негативного тесту, а не одного спільного «невалідний токен».
Чому невалідний токен має давати 401, а не 500?
Бо 500 на невалідному вході — це баг обробки, а не коректна відмова. 401 означає, що сервер свідомо розпізнав токен як непридатний і відхилив його штатно; 500 означає, що якийсь шар коду спробував опрацювати те, що мав відхилити на вході, і впав з необробленим винятком. Різниця та сама, що між 400/422 і 500 на кривому тілі запиту: добре спроєктований сервіс на будь-яке сміття відповідає контрольованою помилкою клієнта, а не падає. Тому падіння на зіпсованому токені саме по собі є знахідкою — воно виказує пропущену валідацію, а іноді й глибшу проблему (наприклад, парсер, що довіряє полю до перевірки підпису). У негативних тестах токена варто асертити саме 401 з окремим повідомленням на кшталт «невалідний токен не має валити сервер».
Що таке атака alg: none і як її швидко перевірити?
alg: none — це JWT, у заголовку якого алгоритм підпису вказано як «none», тобто токен без підпису взагалі. Історична вразливість деяких бібліотек: сервер, побачивши alg: none, приймав payload на віру, не перевіряючи підпис, і зловмисник міг підставити будь-які claims (свій sub, role: admin). Грамотний сервіс мусить відхилити такий токен 401, а не прийняти його. Для тесту це швидка димова перевірка: сформувати токен з alg: none і довільним payload, надіслати й переконатися, що прийшов 401 (не 200 і не 500). Це один із варіантів у наборі негативних токенів поряд із зіпсованим підписом і протермінованим — усі вони мають замикатися на 401.
«Відкликаний токен усе ще працює» — це баг?
Залежить від архітектури, і саме тому результат треба читати уважно, а не механічно заводити дефект. Для чистого stateless-JWT без денойлиста це очікувано: токен самодостатній і живе до свого exp, сервер його не звіряє з жодним сховищем, тож після logout чи зміни пароля він формально ще валідний. Але якщо продукт обіцяє миттєвий вихід із усіх сесій, блокування користувача чи інвалідацію токенів після зміни пароля — а старий токен усе одно пускає, — це реальний баг, який слід підняти, а не замовчати. Тому перед вердиктом розберися в дизайні: чи є денойлист, який TTL, що саме гарантує продукт. Це якраз місце, де кандидат показує, що відрізняє «баг тесту» від «баг дизайну».
Токен протух посеред прогону — що робиш?
Це класична нічна поломка: токен, згенерований на старті, дожив своє в середині довгої сюїти, і половина тестів починає сипати 401. Лікування — не ретраїти наосліп, а централізувати токен: не копіювати його по тестах, а тримати в одному місці, звідки його беруть усі. Далі два робочі підходи: перевіряти exp наперед і оновлювати токен до того, як він протухне, або ловити 401 і тихо оновлювати через refresh, після чого повторювати оригінальний запит. Головне правило гігієни — не хардкодити токен у фікстурах: захардкоджений рядок і є джерелом цієї поломки. Інтерв'юер тут дивиться, чи розумієш різницю між багом продукту і крихкістю сюїти — масовий 401 посеред прогону це майже завжди друге.
Чому автентифікацію в автопрогонах роблять один раз на роль, а не в кожному тесті?
Бо логінитися формою в кожному тесті повільно й крихко, а коли перевірок доступу багато, автентифікація стає окремим джерелом флаку. Замість цього стан здобувають один раз через API й тримають окремий клієнт (контекст) на кожну роль: анонім, користувач, редактор, адмін, чужий власник. Тоді матриця доступу просто вибирає потрібний клієнт для кожної клітинки — clientFor('viewer'), clientFor('admin') — і не платить за логін щоразу. Це швидше (один похід за токеном на роль замість сотні), стабільніше (менше мережевих кроків, менше шансів на моргання) і чистіше в коді (клітинка описує лише роль, метод, шлях і очікуваний код). Побічний бонус — централізований токен легше оновлювати при протермінуванні, бо він живе в одному місці.
Чому тестові креденшали мають бути з мінімумом прав?
Бо токен, яким тест ходить у систему, не повинен бути ширшим, ніж потрібно сценарію — інакше легко отримати оманливо зелений результат. Під адмінським чи проєктним API-ключем усе дозволено, тож перевірка прав фактично не виконується: негативна клітинка «редактор НЕ може видалити» під адміном завжди пройде, бо адмін якраз може. Актор із вузькими правами ловить те, що адмін пропускає: саме бідна роль наштовхується на відсутній гейт і дає очікуваний 403. Тому для кожної негативної перевірки потрібен саме той рівень прав, який вона перевіряє, а не універсальний потужний ключ «щоб точно спрацювало». Гігієна секретів — де зберігати ці токени, як не злити в репозиторій — окрема наскрізна тема розділу про CI/CD.
Тест бачить 403 і радіє — чого ще варто перевірити, крім коду?
Тіло відповіді. Виглядає як «доступ заборонено правильно», а насправді перевірили лише статус — тоді як тіло 403 може містити чутливі поля, які просочилися ще до відмови (сервер устиг покласти дані в об'єкт, а потім «відмовив» кодом). Тому перевіряй і статус, і тіло: у відповіді на відмову не повинно бути захищеного вмісту. Той самий принцип працює в інший бік для BOPLA: після спроби записати заборонене поле мало впевнитися, що прийшов правильний код — треба перечитати об'єкт і переконатися, що поле не змінилося. Загальне правило: код відмови — необхідний, але не достатній оракул; повна перевірка доступу дивиться ще й на те, що поїхало в тілі й що сталося зі станом.
Як спроєктувати data-driven перевірку доступів для трьох ролей?
Описати доступи як таблицю клітинок, де кожен рядок — роль, метод, шлях і очікуваний код, і прогнати її одним параметризованим тестом. Кожна клітинка задає очікування явно: anon GET /projects/1 -> 401, viewer DELETE /projects/1 -> 403, admin DELETE /projects/1 -> 204. Клієнт на роль беруть із фабрики (clientFor(role)), яка тримає окремий автентифікований контекст, тож тіло тесту зводиться до «зроби запит потрібним клієнтом і звір статус». Обов'язково закладай негативні клітинки — саме вони цінні; техніки вибору самих клітинок (класи еквівалентності, межі) — тема тест-дизайну для API. Перевага підходу: додати роль чи ендпоінт — це новий рядок таблиці, а не новий тест; покриття видно з однієї структури, і його легко ревʼювати.
Чому для чесної перевірки BOLA користувачі A і B мають бути різними власниками або тенантами?
Бо інакше перевірка нечесна: якщо A і B насправді бачать спільні дані (один тенант, спільний проєкт, однакова область видимості), то «B прочитав ресурс A» може бути легітимним, і тест або дасть хибний пропуск, або хибне падіння. Щоб негативна клітинка була справжньою, B має бути таким користувачем, якому ресурс A не належить за будь-якою моделлю доступу — а ще краще, окремим тенантом, повністю ізольованим. Тоді 200 на чужому id однозначно є витоком, без «а може, так і треба». Звідси й часта причина фейлу самого тесту: 403 на власному ж ресурсі, коли ресурс створено під одним акаунтом, а запит іде під іншим, або загубився tenant-scope у фікстурі — це баг тесту, не продукту. Тому ізоляцію даних між акаунтами треба будувати свідомо, а не сподіватися, що «різні логіни = різні власники».
Три кейси, де перевірка доступів вирішує, зелений тест чи тихий витік: побудова матриці «роль × ресурс × операція» як таблиці рішень і data-driven прогону, полювання на BOLA під двома акаунтами (читання, зміна, видалення плюс заборонене поле) і негативні сценарії токена з централізованою автентифікацією на роль. Скрізь — що асертити і чому.
Кейс 1. Матриця доступів: від таблиці рішень до одного прогону
Ендпоінт проєктів має три операції й чотири актори. Перш ніж писати код, доступи виписують як таблицю рішень — тоді видно, які клітинки негативні (саме вони цінні), а не тонуть у переліку ручних кейсів.
| Операція \ Актор | Анонім | Власник | Чужий користувач | Адмін |
|---|---|---|---|---|
GET /projects/1 | 401 | 200 | 403/404 | 200 |
PUT /projects/1 | 401 | 200 | 403/404 | 200 |
DELETE /projects/1 | 401 | 403 | 403/404 | 204 |
Читати таблицю треба з негативних клітинок. «Чужий користувач» скрізь має впертися в 403 або 404 — це горизонтальний вектор (чужий ресурс свого ж рівня). «Власник DELETE» дає 403 — вертикальний вектор (видалення дозволене лише адміну). Позитивні клітинки (власник GET -> 200, адмін DELETE -> 204) зазвичай проходять самі собою, бо фічу робили саме під них; дірки живуть там, де очікуємо відмову.
Таблиця один-в-один лягає в параметризований тест. Клієнт на роль беруть із фабрики, яка тримає окремий автентифікований контекст, тож тіло тесту зводиться до «зроби запит потрібним клієнтом і звір статус».
import { test, expect } from '@playwright/test';
type Role = 'anon' | 'owner' | 'other' | 'admin';
const matrix = [
{ role: 'anon', method: 'GET', path: '/api/projects/1', expected: [401] },
{ role: 'owner', method: 'GET', path: '/api/projects/1', expected: [200] },
{ role: 'other', method: 'GET', path: '/api/projects/1', expected: [403, 404] },
{ role: 'admin', method: 'GET', path: '/api/projects/1', expected: [200] },
{ role: 'owner', method: 'DELETE', path: '/api/projects/1', expected: [403] },
{ role: 'other', method: 'DELETE', path: '/api/projects/1', expected: [403, 404] },
{ role: 'admin', method: 'DELETE', path: '/api/projects/1', expected: [204] },
] as const;
for (const cell of matrix) {
test(`${cell.role} ${cell.method} ${cell.path} -> ${cell.expected.join('/')}`, async () => {
const api = clientFor(cell.role);
const res = await api.fetch(cell.path, { method: cell.method });
expect(cell.expected, `несподіваний код доступу`).toContain(res.status());
});
}
Що дивитися і чому:
- Негативні клітинки — це і є перевірка прав.
owner DELETE -> 403іother GET -> 403/404ловлять відсутній гейт; без них лишаються самі позитивні кейси, які «зелені» навіть у зламаному застосунку. expected— масив, не число. Там, де контракт допускає і403, і404(свідоме приховування існування), асертtoContainне червоніє на легітимному варіанті; жорсткийtoBe(403)тут дав би хибне падіння.- Роль — це вибір клієнта, а не логін у тілі тесту. Уся автентифікація захована у
clientFor; додати ендпоінт чи роль — це новий рядок таблиці, а не новий тест.
Кейс 2. BOLA під двома акаунтами: читання, зміна, видалення, заборонене поле
Найдорожчий баг класу API — коли сервіс віддає 200 OK з чужими даними. Перевірка нещадна: створюємо ресурс під користувачем A, а під користувачем B б'ємо в той самий id усіма операціями. Кожна спроба має дати 403 або 404, ніколи 200.
import { test, expect } from '@playwright/test';
test('B не має доступу до замовлення A (read/update/delete)', async () => {
// A створює ресурс і запам'ятовує id
const created = await apiUserA.post('/api/orders', { data: { item: 'X' } });
expect(created.status()).toBe(201);
const orderId = (await created.json()).id;
// B пробує ПРОЧИТАТИ чужий ресурс
const read = await apiUserB.get(`/api/orders/${orderId}`);
expect([403, 404], 'B не має бачити замовлення A').toContain(read.status());
// оракул інвертований: 200 тут — це витік, а не успіх
// B пробує ЗМІНИТИ
const upd = await apiUserB.patch(`/api/orders/${orderId}`, { data: { item: 'Y' } });
expect([403, 404], 'B не має змінювати замовлення A').toContain(upd.status());
// B пробує ВИДАЛИТИ
const del = await apiUserB.delete(`/api/orders/${orderId}`);
expect([403, 404], 'B не має видаляти замовлення A').toContain(del.status());
});
Поруч живе BOPLA (mass assignment): доступ до власного об'єкта є, але роль пише поле, якого чіпати не повинна. Тут мало асертити код — треба перечитати об'єкт і впевнитися, що заборонене поле не змінилося.
test('mass assignment: користувач не може підвищити собі роль через PATCH', async () => {
// B редагує СВІЙ профіль, але підкидає заборонене поле
const res = await apiUserB.patch('/api/me', {
data: { displayName: 'Bob', role: 'admin', isVerified: true },
});
// сервер має або відхилити, або тихо проігнорувати заборонені поля
expect([200, 403]).toContain(res.status());
// головна перевірка — наступний СТАН, а не лише код
const me = await (await apiUserB.get('/api/me')).json();
expect(me.role, 'роль не мала змінитися').not.toBe('admin');
expect(me.isVerified, 'isVerified не мало піднятися самому').toBe(false);
});
Що дивитися і чому:
- A і B мусять бути різними власниками. Якщо вони діляться тенантом чи проєктом,
200у B може бути легітимним — і тест дасть хибний пропуск.403на власному ж ресурсі, навпаки, часто означає баг тесту: ресурс створено під одним акаунтом, а запит іде під іншим. - UUID замість числового id нічого не змінює в цьому тесті. Ми беремо реальний чужий id, отриманий під A, тож перебирати нічого не треба — перевіряється саме гейт власності, а не передбачуваність id.
- Зміна й видалення — така сама BOLA, як читання. Обмежитися
GET— типова хиба;PATCH/DELETEчужого об'єкта часто небезпечніші за читання. - BOPLA ловиться станом. Код
200наPATCHпрофілю сам по собі нормальний — питання в тому, чи застосувалося заборонене поле; тому оракул — перечитаний об'єкт.
Кейс 3. Негативні токени й автентифікація на роль
Токен стає недійсним трьома різними шляхами, і їх легко злити в один «поганий токен». Негативні сценарії ганяють одним параметризованим прогоном, і головний асерт скрізь — 401, ніколи 500.
import { test, expect } from '@playwright/test';
const badTokens = [
{ name: 'порожній', token: '' },
{ name: 'протермінований', token: EXPIRED_JWT },
{ name: 'зіпсований підпис', token: TAMPERED_JWT },
{ name: 'alg:none', token: ALG_NONE_JWT },
];
for (const t of badTokens) {
test(`${t.name} -> 401`, async ({ request }) => {
const res = await request.get('/api/me', {
headers: { Authorization: `Bearer ${t.token}` },
});
expect(res.status(), 'невалідний токен не має валити сервер').toBe(401);
});
}
Коли перевірок доступу багато, автентифікацію централізують: один токен на роль, здобутий раз через API, плюс тихе оновлення на 401, щоб токен не протух посеред довгої сюїти.
// один токен на роль + refresh-на-401, щоб прогін не сипався в середині
async function fetchWithRefresh(role: Role, path: string, init?: RequestInit) {
let res = await request.fetch(path, withAuth(tokenFor(role), init));
if (res.status() === 401) {
await refreshToken(role); // оновлюємо централізований токен ролі
res = await request.fetch(path, withAuth(tokenFor(role), init));
}
return res;
}
Що дивитися і чому:
401, а не500, — окремий асерт із повідомленням. Падіння на зіпсованому токені саме по собі є знахідкою: воно виказує пропущену валідацію, тожtoBe(401)тут важливіший за перевірку тіла помилки.alg: noneі зіпсований підпис — різні дистрактори одного правила. Обидва мають відхилитися401; прийнятийalg: none— це вже вразливість, а не просто червоний тест.- Refresh спрацьовує лише на
401. Ловити ним403не можна:403— це не «токен протух», а «прав немає», і оновлення тут нічого не полагодить, лише замаскує реальну відмову. - Токен не хардкодять у фікстурах. Централізований
tokenFor(role)живе в одному місці — саме тому його можна оновити один раз для всіх тестів ролі, а не полювати за захардкодженими рядками по всій сюїті.
401 vs 403 і коди відмови
- Знаю різницю
401(«хто ти?», автентифікація) і403(«тобі можна?», авторизація), незмінний порядок (спершу401, потім403) і що401тягне заголовокWWW-Authenticateза RFC 9110. - Розумію, чому API інколи навмисно віддає
404замість403— щоб не підтверджувати існування закритого ресурсу, і що асертити треба за контрактом, а не за підручником. - Знаю, що
500замість403— баг обробки, а200замість403— катастрофічний баг (витік), і жоден із них не «косметика».
Матриця «роль × ресурс × операція»
- Можу пояснити три осі покриття: ролі (анонім, користувач, редактор, адмін, чужий власник), ресурси (свої/чужі, публічні/приватні), операції (CRUD плюс list).
- Розумію, чому найцінніші клітинки — негативні («роль НЕ може»), а позитивні проходять самі собою.
- Знаю різницю вертикального вектора (роль тягнеться до операції вищого рівня прав) і горизонтального (роль лізе до чужого ресурсу свого ж рівня).
- Можу перетворити матрицю на один data-driven прогін: таблиця клітинок
роль → метод → шлях → очікуваний код.
BOLA/IDOR і рівень об'єкта
- Знаю, що IDOR і BOLA — одна вада під двома іменами, і що BOLA очолює OWASP API Security Top 10 (2023).
- Можу описати механізм: ендпоінт перевіряє токен, але не звіряє власність об'єкта, тож зміна id відкриває чужі дані.
- Розумію, чому UUID замість числового id НЕ закриває BOLA — це замок, а не стіна; захищає лише гейт власності на сервері.
- Пам'ятаю перевіряти не лише читання, а й зміну та видалення чужого об'єкта — і що
200на чуже є критичним багом. - Знаю BOPLA / mass assignment: доступ до об'єкта є, але роль пише заборонене поле (
role,isVerified) — перевіряю і код, і наступний стан об'єкта.
Негативні сценарії токена
- Знаю три шляхи, якими токен стає недійсним: протермінований (
expу минулому), невалідний (зіпсований підпис /alg: none/ крива схема), відкликаний (logout, зміна пароля, блокування). - Розумію головне правило: будь-яке сміття в токені (зіпсований підпис,
alg: none, крива схема) має давати401, ніколи500— і грамотний сервіс мусить відхилитиalg: none, а не прийняти payload на віру. - Читаю результат «відкликаний токен ще працює» з огляду на архітектуру: для stateless-JWT без денойлиста це очікувано, але якщо продукт обіцяє миттєвий logout — це реальний баг.
Токени в автопрогонах
- Можу пояснити, чому автентифікацію роблять один раз на роль (окремий клієнт/контекст), а не логіняться формою в кожному тесті.
- Знаю ліки від протермінування посеред прогону: централізувати токен, перевіряти
expнаперед або ловити401і оновлювати через refresh — і не хардкодити токен у фікстурах. - Розумію, чому тестові креденшали мають бути з мінімумом прав: під адмін-ключем перевірка прав фактично не виконується.
- Знаю, чому для чесного BOLA користувачі A і B мають бути різними власниками або тенантами, і що
403на власному ресурсі часто означає баг тесту (не той акаунт, загублений tenant-scope), а не продукту. - Пам'ятаю перевіряти не лише статус відмови, а й тіло — у
403не повинно бути чутливих полів, що просочилися до відмови.
На яке питання відповідає код 403 Forbidden?
Питання
401 проти 403 — на які різні питання вони відповідають?