Главная / Статьи / 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 (...).

Сведение более двадцати тысяч идентификаторов в одно заявление не освобождает эти строки. Количество итераций сокращается, но кардинальность остаётся прежней; глубокие уровни всё ещё существуют. Группировка — это способ повышения эффективности, а не предел. Эффективность ошибочно принимали за ограничение.

Вход в систему и идентичность. Запрашивающий пользователь был авторизован. Идентичность отвечает на вопрос «кто», а не «насколько это дорогостояще».

Разрешения по полям. Каждое выбранное поле было разрешено для данного пользователя. Авторизация прошла успешно. Проблемой был объём законных операций с графом, а не запрещённые данные.

Насколько ужасен тот же пробел при атаке

После восстановления было потрачено несколько часов на моделирование враждебного использования той же уязвимости. Именно это моделирование стало причиной создания этого текста.

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

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 })
      ]
    })
  ]
});

Подключение компонентов к системе происходит легко; сложность заключается в выборе правильных весов. Скалярные значения стоят один единиц, списки — first раз стоимость их элементов. Для резолверов, обращающихся к сторонним сервисам, устанавливаются ручные веса, например, пятьдесят.

Два дня на настройку на основе производственных логов позволили получить приблизительно правильные цифры. Приблизительно достаточно было.

3. Ограничения на количество псевдонимов и узлов

Установите лимиты на псевдонимы за операцию и общее количество узлов AST.

Пятьдесят псевдонимов вместе с верхним пределом для узлов покрывали реальные условия использования; ни один серьезный клиент не приближался к этим значениям. Использование тысячи псевдонимов приводит к ошибкам проверки, а не к масштабным проблемам с решением задач.

Пакетные решения помогают. GraphQL Armor объединяет контроль глубины, стоимости, псевдонимов, директив и возможностей инспекции. Командам, начинающим с нуля, следует установить и настроить этот пакет перед тем, как создавать всё заново.

4. Таймаут запросов в базе данных

Последняя линия обороны — и самый быстрый способ решения проблем:

ALTER ROLE api_user SET statement_timeout = '10s';

Запросы, выполняемые от имени роли приложения, прекращаются через десять секунд. Речь идет не о HTTP-сокете, а о самом SQL. Уже это позволило бы сократить время простоя с примерно 94 секунд работы БД до десяти, без каких-либо знаний GraphQL.

В ту же неделю через конфигурацию была отключена возможность инспекции в производственной среде — это была мера безопасности, которую ранее игнорировали.

Уроки для более ранней версии той же команды

Три напоминания.

Контролируйте стоимость обработки, а не просто количество запросов. Подсчет количества HTTP-запросов в минуту — это привычка REST. GraphQL позволяет скрыть произвольную работу в одном запросе типа POST. Если счетчик учитывает только запросы, на самом деле счетчика нет. Необходимо учитывать стоимость обработки каждого запроса.

Крайние сроки должны прерывать выполнение работы. Таймаут HTTP в тридцать секунд казался защитным мерой, но на самом деле лишь скрывал проблемы для пользователей, пока бэкенд продолжал работать. Устанавливайте таймауты там, где выполняется работа — для Postgres это параметр statement_timeout в настройках роли.

DataLoader не ограничивает размер данных. Группировка запросов устраняет эффект N+1 и снижает стоимость обработки крупных запросов за один раунд-трип, но не делает их меньше по размеру. Эффективность и верхние пределы — это разные проблемы; необходимо учитывать обе.

Чек-лист для производственного GraphQL

Проверьте это как можно скорее. Большинство проверок занимают несколько минут.

  1. Интроспекция отключена в производственной среде? В противном случае вся схема становится общедоступной.
  2. Установлен лимит глубины запросов? Измерьте максимальную глубину допустимых запросов; установите лимит немного выше этого значения.
  3. Установлен бюджет на расходы? Один только лимит глубины не учитывает запросы с большим количеством элементов.
  4. Установлен лимит алиасов? Часто игнорируется; помогает предотвратить чрезмерное количество запросов.
  5. Роль в базе данных statement_timeout настроена? Определяет время работы запроса; включает всю соответствующую категорию настроек.
  • Ограничения скорости основаны на стоимости, а не только на количестве запросов? Ограничения, основанные только на количестве запросов, — это пустая трата времени.
  • Эта команда начинала с одного из шести вариантов. Сейчас они используют все шесть; четыре из них появились всего за один день.

    Правило

    Следует помнить о следующем различии:

    Концовки REST по своей природе ограничивают объем работы. GraphQL позволяет клиентам самим устанавливать такие ограничения. Если сервер не восстановит явно заданное ограничение, оно считается удаленным, а не просто перенесенным.

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

    Почти час простоя начался, когда один из коллег нажал кнопку запуска в студии. Это дружелюбная версия событий. В агрессивной версии требовались лишь учетная запись и мгновенное решение; этого так и не произошло, потому что никто даже не попытался.

    Сначала убедитесь в настройках самоанализа. Это займет несколько секунд, и многие команды уже знают ответ.