Защита GraphQL: стоимость запроса, глубина и федерация
Публичный GraphQL-эндпоинт даёт клиенту составлять запрос, так что один запрос разворачивается в миллионы вызовов резолверов и БД — DoS-вектор, которого у REST нет. Защищайся лимитом глубины, анализом стоимости и persisted-запросами; масштабируй через Apollo Federation.
GraphQL-эндпоинт был публичным восемь месяцев. Для графа на чтение авторизация не требовалась — он питал партнёрскую интеграцию, и команда гордилась тем, какой он гибкий. В 02:14 зазвонил телефон дежурного: p99 API ушёл вертикально вверх, соединения Postgres упёрлись в максимум, а машина свопила. Всплеска числа запросов не было. Был один запрос. Кто-то — скрапер, любопытный бот, это так и не выяснили — отправил единственный запрос: user { friends { friends { friends { posts { author { friends } } } } } }. Схема радостно его резолвила, и каждый уровень умножал fan-out: сотня друзей, у каждого по сотне друзей, у каждого посты, каждый пост заново резолвит автора. Один HTTP-запрос превратился в несколько миллионов вызовов резолверов и сопоставимую гору чтений из базы. Сервер его не отклонил — отклонять было нечем, ничего не настроено. Этот урок — про контроли, которые там должны были быть: лимит глубины, анализ стоимости запроса, persisted-запросы, а затем федерацию, когда узким местом становится сам единый граф.
Почему GraphQL нужна защита, которой не нужно REST
В REST форму каждого ответа владеет сервер. GET /users/42/friends возвращает ограниченную страницу; стоимость этого эндпоинта фиксирована и известна в момент написания. Клиент не может попросить /users/42/friends/friends/friends — такого маршрута нет, и чтобы получить вложенные данные, клиент обязан сделать больше отдельно лимитируемых запросов.
GraphQL переворачивает это. Запрос составляет клиент — он выбирает поля, глубину и ширину в одном запросе. Эта выразительность — главная фича, и она же — поверхность атаки: клиент может попросить произвольно глубокие, произвольно широкие, рекурсивные данные одним HTTP-вызовом, и сервер послушно их зарезолвит. Самоссылочный тип вроде User.friends: [User] позволяет запросу вкладываться сам в себя, пока ответ не станет комбинаторным взрывом. Один запрос, без авторизации, превращается в миллионы вызовов резолверов и обращений к БД. Это DoS-вектор, уникальный для выставленного наружу GraphQL-эндпоинта, и потому публичному GraphQL API нужны защиты выполнения, которые REST API получает бесплатно от своих фиксированных маршрутов.
Защита многослойная, и слои работают до выполнения — ты отклоняешь вредоносный запрос на этапе валидации, а не переживаешь его.
Контроль 1 — лимит глубины
Первая защита ограничивает, насколько глубоко может вкладываться запрос. Правило валидации обходит AST запроса и отклоняет любую операцию, чьё множество выборки вложено глубже максимума — обычно 7–10 уровней для реального приложения. Именно это останавливает бомбу рекурсивных друзей: friends { friends { friends { ... } } } превышает лимит глубины и отклоняется на валидации, до того как запустится хоть один резолвер.
// main.ts — wire a depth-limit validation rule into Apollo's validationRules
import depthLimit from 'graphql-depth-limit';
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
// any operation nested deeper than 7 selection sets is rejected at validation
validationRules: [depthLimit(7)],
});Лимит глубины дёшев, статичен и ловит самую очевидную рекурсию. Но у него есть слепое пятно, и это слепое пятно — следующий контроль.
Контроль 2 — сложность / анализ стоимости запроса
Запрос может быть неглубоким и всё равно разрушительным. users(first: 10000) { posts(first: 1000) { comments(first: 1000) } } — это всего три уровня в глубину, он проскальзывает мимо любого лимита глубины, — но умножается примерно до десяти миллиардов узлов. Лимит глубины считает вложенность; он слеп к ширине и к множителям пагинации.
Анализ стоимости это чинит. Ты назначаешь числовую сложность каждому полю — скалярные поля дёшевы, поля-списки дороги, а стоимость поля-списка умножается на его аргумент пагинации, — затем суммируешь стоимость всего запроса до выполнения и отклоняешь всё, что превышает бюджет (обычно 1000). NestJS поддерживает это через плагин сложности, считающий оценку по per-field эстиматорам или значению complexity на поле.
// Аннотируй дорогое поле и умножай на аргумент пагинации
@Field(() => [Post], { complexity: 5 })
posts: Post[];
// A plugin sums the query's complexity at validation time and rejects over budget
import { ComplexityPlugin } from './complexity.plugin'; // computes score, throws over maxComplexity (e.g. 1000)Эстиматор для пагинированного списка умножает базовую стоимость поля на first/limit, так что users(first: 10000) набирает 10000× стоимости на пользователя и сам по себе пробивает бюджет. Это контроль, который ловит широкую-но-неглубокую бомбу, которую лимит глубины пропускает. Нужны оба: глубина против рекурсии, стоимость против fan-out.
▸Почему это работает
Почему лимита глубины в одиночку недостаточно и что добавляет анализ сложности? Неглубокий запрос всё равно может быть разрушительно дорогим. users(first: 10000) { posts(first: 1000) { comments(first: 1000) } } — это всего три уровня в глубину, так что лимит глубины 7 пропускает его насквозь, — однако аргументы пагинации умножаются примерно до десяти миллиардов узлов и плавят базу. Лимит глубины считает, насколько глубоко ты вкладываешься; он совершенно слеп к тому, насколько широк каждый уровень, и к эффекту множителя first/limit. Анализ сложности назначает оценку, которая умножает размеры списков на их аргументы пагинации, так что тот же широкий-но-неглубокий запрос набирает далеко за бюджет и отклоняется на валидации. Два контроля покрывают ортогональные оси — глубина останавливает рекурсию, стоимость останавливает fan-out, — и защищённый эндпоинт запускает оба, плюс жёсткие ограничения на сами аргументы пагинации, таймауты запроса и rate limiting.
Контроль 3 — persisted-запросы (сильнейший рычаг)
Предыдущие два контроля ограничивают произвольный запрос. Persisted-запросы меняют игру, убирая произвольность. Вместо приёма любой строки запроса клиент шлёт хеш запроса, зарегистрированного заранее; сервер ищет хеш и выполняет только ту операцию, которую уже знает. Automatic Persisted Queries (APQ) делают это в первую очередь чтобы уменьшить полезную нагрузку запроса — клиент шлёт короткий хеш вместо длинной строки запроса.
Выигрыш в безопасности приходит от запуска persisted-запросов в режиме allow-list: сервер выполняет только операции из проверенного, заранее зарегистрированного набора и отклоняет любой хеш, которого не видел. В этом режиме DoS произвольным запросом устраняется напрочь — атакующий не может прислать бомбу рекурсивных друзей, потому что сервер не запустит запрос, не выданный ему на этапе сборки. Цена — гибкость: публичный или сторонний API, чьи клиенты законно составляют новые запросы, не может занести их в allow-list. Для first-party-приложения — твоих собственных веб- и мобильных клиентов, поставляющих фиксированный известный набор операций, — allow-list persisted-запросы — единственный сильнейший рычаг защиты, и они складываются с лимитами глубины и стоимости как defense in depth.
Рантайм-родственник: N+1 и DataLoader
Анализ стоимости ограничивает то, что клиент может попросить; он ничего не говорит о том, что твои резолверы на самом деле делают, отвечая. Fan-out резолверов заново вносит проблему N+1 — резолвинг списка из N родителей с одним дочерним запросом на родителя, — которую (как разбиралось ранее в этом юните) DataLoader решает, батчируя N чтений в одно и кешируя на запрос. Думай о них как о паре: лимит стоимости — статический бюджет на форму запроса, DataLoader — рантайм-бюджет на работу резолверов. Защищённому API нужны оба, потому что запрос, прошедший ворота стоимости, всё равно может выдать N+1 запросов, если его резолверы наивны.
Федерация — когда единый граф становится узким местом
Контроли выше защищают один граф. Приходилось ли ждать ревью схемы от другой команды, прежде чем отгрузить собственный тип? Именно этот беспорядок владения призвана растворить федерация. По мере роста графа единая монолитная схема и набор резолверов становятся узким местом деплоя — каждая команда правит один файл, каждое изменение — полный передеплой. Apollo Federation (в NestJS — ApolloFederationDriver) разбивает граф между сервисами. Каждый сабграф владеет своими типами, помечает сущности, которыми владеет, через @key(fields: "id") и предоставляет резолвер __resolveReference, чтобы другие сабграфы могли расширять его сущности по ссылке. Шлюз (gateway) композирует сабграфы в один суперграф и планирует кросс-сервисные запросы, доставая поля каждой сущности из сабграфа-владельца.
# users subgraph — owns the User entity, identified by id
type User @key(fields: "id") {
id: ID!
name: String!
}# reviews subgraph — extends User by reference, adds the fields it owns
type User @key(fields: "id") {
id: ID! @external
reviews: [Review!]! # resolved here via __resolveReference(id)
}Это современный декларативный преемник более старого schema stitching, где шлюз вручную сливал схемы, а кросс-сервисную обвязку ты писал руками. Федерация делает связи декларативными — @key и __resolveReference — и позволяет шлюзу планировать запрос. Критически: защита не переезжает на шлюз и не останавливается там — лимиты глубины, анализ стоимости и persisted-запросы по-прежнему живут на краю, потому что суперграф ровно так же композируем — а значит ровно так же уязвим, — как единый граф, который он заменил.
Тебе нужно защитить публичный GraphQL API от DoS по стоимости запроса, где один составленный клиентом запрос может развернуться в миллионы вызовов резолверов и БД. Какая наилучшая многослойная защита?
Почему один GraphQL-запрос может быть DoS-вектором, тогда как один REST-эндпоинт обычно нет?
Запрос всего в три уровня глубины всё равно плавит базу. Почему лимит глубины его пропускает и какой контроль его ловит?
- 01Объясни, почему публичный GraphQL-эндпоинт — DoS-вектор, которым не является REST, и три контроля, которые его защищают — точно про то, что каждый ловит и упускает.
- 02Объясни Apollo Federation: задачу, которую она решает, как сабграфы и шлюз композируют граф, роль @key и __resolveReference, чем она отличается от schema stitching и где живёт защита.
Публичный GraphQL-эндпоинт — DoS-вектор, которым REST не является, потому что запрос составляет клиент и может потребовать произвольно глубокие, широкие, рекурсивные данные одним запросом — самоссылочный тип позволяет user { friends { friends { ... } } } развернуться в миллионы вызовов резолверов и БД без авторизации. Защита многослойна и работает до выполнения. Лимит глубины ограничивает вложенность ~7-10 и отклоняет рекурсивную бомбу, но слеп к ширине и множителям пагинации. Анализ сложности/стоимости назначает стоимость на поле, умножает поля-списки на их аргумент пагинации, суммирует запрос и отклоняет за бюджетом (~1000) — ловя широкий-но-неглубокий запрос вроде users(first: 10000), который всего три уровня в глубину, но умножается до миллиардов узлов; нужны оба контроля, потому что они покрывают ортогональные оси. Persisted-запросы в режиме allow-list шлют хеш заранее зарегистрированного запроса и выполняют только проверенные операции, целиком устраняя DoS произвольным запросом — сильнейший рычаг для first-party-клиентов, слишком жёсткий для публичных. Ограничения пагинации, таймауты и rate limiting — внешние слои, а DataLoader ограничивает рантайм-N+1, которым анализ стоимости не занимается. Когда сам единый граф становится узким местом деплоя и владения, Apollo Federation разбивает его на сабграфы, владеющие сущностями через @key и резолвящие ссылки через __resolveReference, а шлюз композирует их в один суперграф — современный преемник ручного schema stitching. Ворота защиты остаются на краю, потому что суперграф ровно так же уязвим, как единый граф, который он заменил. Теперь, когда открываешь GraphQL API для партнёров или публики, знаешь, какие контроли подключить до первого внешнего запроса — и какой один вызов уронит базу, если этого не сделать.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.