Головна / Статті / GraphQL-запит, який виснажив пул баз даних

GraphQL-запит, який виснажив пул баз даних

Один вкладений документ GraphQL спричинив збій у продакшн-базі даних. Чому не спрацювали обмеження на кількість запитів, таймаути HTTP та DataLoader — і чотири заходи безпеки, які нарешті стримали ситуацію.

1617 слів

Одна лише операція GraphQL POST вивела API з ладу майже на годину — і це не було навмисно

Проблеми почалися одразу після восьмої у вечір неділі.

Процесор Postgres працював на повній потужності. У системі більше не залишилося вільних з’єднань. У всіх клієнтів спостерігалися таймаути при вході. Публічний API був повністю недоступний у вечір неділі, що зазвичай є періодом найвищого навантаження на цей продукт.

Рівно один HTTP-запит. Один POST /graphql вже провів близько півтори хвилини на обробці даних у базі та все ще функціонував. Ця єдина операція вже витратила більше часу на роботу з базою, ніж усі попередні кілька годин звичайного трафіку.

У наступному описано структуру документа, чому існуючі механізми контролю її не виявили, а також чотири параметри, які стали відомими через кілька днів. Наприкінці наведений найгірший сценарій: як мало зусиль знадобилося б зловмиснику, щоб скористатися тією самою вразливістю.

Запит

Структурно точний (хоча й скорочений), документ нагадував:

query {
  organizations {
    members {
      user {
        organizations {
          members {
            user {
              organizations {
                members {
                  user { id, email }
                }
              }
            }
          }
        }
      }
    }
  }
}

Структура складалася з семи рівнів, які утворювали цикл: організації містили членство, членства вказували на людей, а люди знову належали до організацій.

Ці краї є справжніми та двонапрямковими, тому їх моделювання саме так є правильним. Резолвери працювали належним чином. Кожен окремий виклик SQL працював добре та швидко.

Проблеми виникли через комбінаторне зростання.

Типові облікові записи знаходяться приблизно у трьох організаціях. Типові організації містять близько п’ятдесяти осіб. Розширення цієї структури дає такі показники:

  • глибина 1 → ~3 організації
  • глибина 2 → ~150 участей
  • глибина 3 → ~150 користувачів
  • глибина 4 → ~450 організацій
  • глибина 5 → ~22,5 тис. участей
  • глибина 6 → ~22,5 тис. користувачів
  • глибина 7 → ~67,5 тис. організацій

У кінцевому підсумку залишається майже сімдесят тисяч елементів, кожен з яких спричиняє додаткові запити до участей. Розширення все ще тривало, коли оператори зупинили процес.

Це був короткий текстовий документ. Жодних проблем з автентифікацією. Жодних можливостей для вставки коду. Нічого, що міг би виявити сканер. Схема просто дотримувалася графа, який вона публікувала.

Випадкове, а не зловмисне

Походження має значення для уроку.

Дзвінок було здійснено через автентифіковану сесію одного з співробітників. Інженер з мобільних технологій досліджував схему в Apollo Studio, складаючи план потреб у даних інтерфейсу, та відкривав вкладені поля, щоб побачити, що там є.

Вони натиснули «виконати», побачили, як інтерфейс завис, звинуватили мережу та закрили вкладку браузера.

Закриття вкладки не припиняє роботу засобів обробки на серверній стороні. Сокет зник; виконання продовжилося; база даних продовжувала обробляти десятки тисяч варіантів, поки ніхто не чекав на відповідь.

Вони дізналися про збою лише зі запису подій від понеділка. Нічого агресивного не сталося: вони використовували інструмент для дослідження, який надала компанія, згідно з схемою, яку також надала компанія.

Чому існуючі захисти не спрацювали

Контролі існували. Жоден з них не відповідав цьому способу збою — і саме в цьому суть проблеми.

Ліміти запитів за IP. Обмеження рахуються за кількістю HTTP-запитів на хвилину. Один запит — це один запит. Механізм обмеження правильно дозволив його пройти.

Терміни виконання HTTP-запитів. Було досягнуто таймауту балансуючого пристрою у тридцять секунд; клієнт отримав код 504. Бекенд-сервер SQL продовжував працювати, оскільки закриття сокета не скасовує роботу, яка відбувається позаду нього. Клієнтів обманули; сервер продовжував працювати без зупинки.

DataLoader. Команди часто використовують групування даних як запобіжний захід.

За одну добу DataLoader об’єднує дублікати завантажень ентитетів та справді усуває проблему класичного N+1. На найнижчому рівні взаємодії тисячі пошуків об’єднуються у кілька операторів WHERE id IN (...).

Об’єднання трохи більше ніж двадцяти тисяч ID в одне запитання не робить ці рядки вільними. Кількість поїздок зменшується, але кардинальність — ні; глибші рівні все ще існують. Блокування даних — це спосіб підвищення ефективності, а не її межа. Ефективність часто плутали з обмеженнями.

Увійшов та ідентичність. Викликаючий користувач був усиновлений системою. Ідентичність відповідає на питання хто, а не наскільки це дорого.

Дозволи за полем. Кожне обране поле було дозволене для цього користувача. Аутентифікація пройшла успішно. Проблемою була кількість законних операцій з графом, а не заборонені дані.

Наскільки потворно виглядає та сама прогалина під час атаки

Після відновлення було витрачено півдня на моделювання ворожого використання цієї ж прогалини. Саме це моделювання є причиною існування цього тексту.

Псевдоніми дозволяють одному документу повторювати поле з різними параметрами:

mutation {
  a1: login(email: "target@company.com", password: "000001") { token }
  a2: login(email: "target@company.com", password: "000002") { token }
  a3: login(email: "target@company.com", password: "000003") { token }
  # ... two thousand more
}

Ще один HTTP-запит. Обмеження швидкості дії стосуються лише одного запиту. Лічильник блокування після п’яти невдалих спроб знаходився всередині механізму резолювання та правильно фіксував тисячі спроб.

Тож атака типу „спрей паролів“ була зупинена випадково — лічильник виявився саме там, де фактично відбувається обробка запитів.

Загальна структура залишилась незмінною. Будь-який ресурс із високими витратами на обробку міг бути використаний сотні разів у одному запиті, а обмеження швидкості цього не враховували: пошук, звіти про завдання, виклики сторонніх сервісів. Роки застосування механізмів обмеження у стилі REST зустрілися з API, яке не поводиться за принципами REST.

У продакшені також залишилась увімкнена функція інтроспекції. Будь-хто міг отримати повну структуру типів — усі зв’язки включно — та створювати документи з максимальними витратами без необхідності припущень.

Ніхто цього не робив. Удача не є засобом контролю.

Чотири обмеження, які були додані пізніше

Через кілька днів роботи інженерів були додані наступні обмеження, впорядковані за ступенем впливу.

1. Обмеження глибини

Перший та найпростіший спосіб: відхиляти документи, які знаходяться на глибині, що перевищує встановлений ліміт.

import depthLimit from 'graphql-depth-limit';

const server = new ApolloServer({
  schema,
  validationRules: [depthLimit(7)]
});

Було проведено дослідження реального трафіку клієнтів. Найглибші законні операції зупинялися на п’ятій глибині. Ліміт було підвищено до семи — це дає можливість для зростання та дозволяє відхиляти патологічні випадки ще до того, як почнуть працювати резолвери.

Правила верифікації перевіряють оброблений документ перед виконанням, тому відхилення є майже безкоштовним.

2. Аналіз витрат на запит

Глибина сама по собі недостатня. Навіть поверхневий документ, який запитує десять тисяч елементів списку, залишається дуже об’ємним.

Система оцінки витрат присвоює ваги полям, множить їх на кількість елементів у списку та відхиляє запити, які перевищують встановлений бюджет.

const server = new ApolloServer({
  schema,
  plugins: [
    createComplexityPlugin({
      maximumComplexity: 1000,
      estimators: [
        fieldExtensionsEstimator(),
        simpleEstimator({ defaultComplexity: 1 })
      ]
    })
  ]
});

Підключення елементів є простим; вибір ваг — це складна робота. Скалярні значення коштують одиницю. Списки коштують першу кількість разів вартість їхнього елемента. Резолвери, які звертаються до сторонніх сервісів, отримують ручно встановлені ваги, наприклад п’ятдесят.

Два дні наналаштувань на основі логів продакшну допомогли отримати приблизно правильні цифри. Приблизно достатньо було.

3. Обмеження кількості псевдонімів та вузлів

Встановіть ліміти на кількість псевдонімів за операцією та загальну кількість вузлів AST.

П’ятдесят псевдонімів разом із верхньою межею кількості вузлів враховували реальні умови; жоден чесний клієнт не наближався до цих значень. Використання тисячі псевдонімів призводить до помилок перевірки, а не до масштабних проблем з обробкою.

Пакетні інструменти захисту допомагають. GraphQL Armor об’єднує контроль глибини, витрат, псевдонімів, директив та можливостей інтроспекції. Командам, які починають роботу з нуля, слід встановити та налаштувати цей пакет перед тим, як створювати все заново.

4. Таймаут запитів у базі даних

Остання лінія захисту — та найшвидший спосіб досягнення результату:

ALTER ROLE api_user SET statement_timeout = '10s';

Запити в межах ролі додатку зникають через десять секунд. Йдеться не про HTTP-сокет, а саме про SQL. Лише це могло б скоротити тривалість перерви з приблизно 94 секунд роботи БД до десяти, без жодних знань у GraphQL.

Функція інтроспекції в продакшені була вимкнена за допомогою конфігурації тієї ж тижня — це була елементарна процедура на початковому етапі, яку було проігноровано.

Уроки для попередньої версії тієї ж команди

Три нагадування.

Контролюйте витрати, а не лише кількість запитів. Підрахунок запитів за хвилину — це звичка REST. GraphQL дозволяє приховати будь-яку роботу в одному POST-запиті. Якщо лічильник бачить лише запити, насправді немає справжнього лічильника. Враховуйте витрати.

Терміни мають зупиняти роботу. Таймаут HTTP у тридцять секунд здавався захисним елементом, але лише приховував пошкодження від користувачів, поки бекенди продовжували працювати. Встановлюйте таймаути там, де відбувається обробка даних — для Postgres це statement_timeout у ролі.

DataLoader не обмежує розмір даних. Групування запитів у пакети усуває ефект N+1 та робить більш об’ємні запити дешевшими за один зворотний шлях, але не робить їх меншими за обсяг. Ефективність та верхні межі — це окремі проблеми; потрібно враховувати обидві.

Чек-лист для продакшн-версії GraphQL

Перевірте це якнайшвидше. Більшість кроків займають кілька хвилин.

  1. Інтроспекція вимкнена у продакшені? Інакше весь схематизм буде доступний для всіх.
  2. Встановлена межа глибини? Виміряйте найглибші запити; встановіть межу трохи вище цих значень.
  3. Встановлений бюджет витрат? Лише межа глибини не враховує довгі списки.
  4. Встановлена межа алиасів? Часто її не встановлюють; це допомагає уникнути неконтрольованого поширення запитів.
  5. Роль у базі даних statement_timeout налаштована? Вказує кількість хвилин роботи запиту; охоплює всю цю категорію запитів.
  • Обмеження швидкості ґрунтуються на витратах, а не лише на кількості запитів? Обмеження, засновані лише на кількості, — це формальність.
  • Ця команда починала з одного із шести варіантів. Зараз вони використовують усі шість; чотири з них прийшли протягом одного дня.

    Правило

    Запам’ятайте це розрізнення:

    Кінцеві точки REST за своєю суттю обмежують роботу. GraphQL дозволяє клієнтам самим встановлювати обмеження. Якщо сервер не відновлює явне обмеження, воно не переноситься — його просто видаляють.

    Раніше припускали, що сервери визначають, наскільки дорогим може бути запит. GraphQL передає цю владу тому, хто пише документ — що робить його потужним та пояснює поширеність його використання. Відповідальність має бути повернена у код; фреймворк цього не зробить.

    Майже година перерви почалася тоді, коли один із колег натиснув кнопку «Запустити» у студії. Це дружелюбна версія ситуації. Агресивна версія вимагала лише облікового запису та короткої думки; вона ніколи не траплялася лише через те, що ніхто не намагався.

    Спочатку перевірте налаштування інтроспекції. Це займає кілька секунд, і багато команд вже знають відповідь.