vyvchy
    Теми розділу

    04 · API-тестування

    Авторизація в 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/1401200403/404200
    PUT /projects/1401200403/404200
    DELETE /projects/1401403403/404204

    Два вектори варто назвати окремо, бо термінологію питають на співбесіді. Коли роль тягнеться до операції вищого рівня прав (користувач викликає адмінський ендпоінт) — це вертикальний вектор; коли роль лізе до чужого ресурсу свого ж рівня — горизонтальний. Атакувальний кут і формальні означення ескалацій — тема розділу про безпеку (функціонально нам достатньо різниці «вгору по правах» проти «вбік до чужого»).

    Матриця природно лягає в 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 роблять перебір справою кількох секунд.

    СерверКористувач BСерверКористувач BАвтентифікація: токен B валіднийПеревірка власності 1025: ПРОПУЩЕНАЧужі дані витеклиGET /api/orders/1025 (Bearer токен B)200 OK + замовлення користувача AСерверКористувач BСерверКористувач BАвтентифікація: токен B валіднийПеревірка власності 1025: ПРОПУЩЕНАЧужі дані витеклиGET /api/orders/1025 (Bearer токен B)200 OK + замовлення користувача A

    Ключове місце — гейт власності (object-level check), якого на діаграмі бракує. Ось де в конвеєрі авторизації живе BOLA — а поверхом вище, на гейті ролей, ловиться вертикальний вектор (Broken Function Level Authorization, BFLA):

    Ні

    Так

    Ні

    Так

    Ні

    Так

    Запит із токеном

    Токен валідний?

    401

    Роль дозволяє операцію?

    403 - тут ловиться BFLA/вертикаль

    Об'єкт належить користувачу?

    403 або 404 - тут ловиться BOLA/горизонталь

    200

    Ні

    Так

    Ні

    Так

    Ні

    Так

    Запит із токеном

    Токен валідний?

    401

    Роль дозволяє операцію?

    403 - тут ловиться BFLA/вертикаль

    Об'єкт належить користувачу?

    403 або 404 - тут ловиться BOLA/горизонталь

    200

    Функціональна перевірка проста й нещадна: створи ресурс під користувачем 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
    Протермінований токенвалідний підпис, минулий exp401
    Зіпсований підпис / 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; інтерв'юер дивиться, чи розумієш різницю між багом продукту і крихкістю сюїти.

    Джерела