[Перевод] Подписывание идентификаторов ресурсов и защита API от DDoS-атак
Мне довелось поучаствовать в работе над этим проектом в качестве консультанта. Посещаемость ресурса составляет порядка 200 миллионов уникальных пользователей в месяц. Такая популярность означает и высокий уровень рисков в сфере информационной безопасности, в частности, это риск подвергнуться различным видам атак, самые распространённые среди которых — DDoS. Организация, которую называть не буду, внедрила широкий спектр решений для предотвращения воздействия подобных атак на работоспособность сервиса.
Эти защитные системы вполне обычны. В их основе — сборка контента на граничных узлах (CDN, ESI) и применение многоуровневого пассивного кэша.
Подобная конструкция хороша для обеспечения стабильной работы сервиса. Однако, создание приложений, рассчитанных на использование пассивного кэша, означает большую дополнительную нагрузку на команды программистов. Ниже мы обсудим это подробнее.
Работая над проектом, я обнаружил способ защиты от DDoS-атак, который, обладая теми же преимуществами, что и пассивный кэш, не регламентирует так же жёстко архитектуру служб, лежащих в основе системы. О нём сегодня и пойдёт речь.
Что такое пассивный кэш?
Сервис, использующий пассивный кэш может читать данные только из кэша. Об источнике происхождения этих данных сервис ничего не знает.
В подобной конфигурации система поддержки кэша — это хранилище данных в формате «ключ-значение» (например, Redis), а основной источник данных — это система управления реляционными базами данных (например, Oracle Database).
Сервис с активным кэшем сначала пытается прочесть данные из кэша, а если это не удаётся — обращается к основному источнику данных.
Использование пассивного кэша для защиты от DDoS
Использование архитектуры пассивного кэша гарантирует то, что основной сервис с источником исходных данных никогда не столкнётся с неожиданно большим объёмом запросов. Независимо от того, сколько и каких запросов будет выполняться к сервису, основной источник данных используется только службой очереди сообщений для заполнения хранилища данных кэша.
Например, http://gajus.com/blog/ — это служба блогов. Здесь размещают статьи. Клиент может получать доступ к отдельным статьям, используя их уникальные индексы. Вот примеры адресов статей:
- http://gajus.com/blog/1/behaviour-driven-development-with-javascript
- http://gajus.com/blog/2/the-definitive-guide-to-the-javascript-generators
- http://gajus.com/blog/8/using-mysql-in-node-js
- http://gajus.com/blog/9/using-dataloader-to-batch-requests
В этом примере »1»,»2»,»8» и »9» — это идентификаторы ресурсов, уникальные индексы, которые применяются для доступа к данным в хранилище.
Рассматриваемая служба блогов использует активный кэш. Когда клиент запрашивает статью с индексом »1», сервис обращается к кэшу и возвращает результат, или (если в кэше нет записи с данными запрошенной статьи), он обращается к базе данных, получает результат и сохраняет его на некоторое время в кэше.
Если злоумышленник организует атаку, которая подразумевает выполнение HTTP-запросов для получения статьи с индексом »1», все эти запросы будут обслужены хранилищем кэша. Запрос данных из хранилища типа «ключ-значение» не требует большого расхода ресурсов. Для того, чтобы успешно атаковать систему, нагрузив сверх меры подсистему поиска в подобном хранилище, злоумышленнику понадобились бы очень серьёзные мощности.
Ситуация сильно меняется, если при атаке используются произвольные значения для конструирования идентификатора статьи, например, значения в диапазоне от 1 до 1 миллиона. Теперь каждый запрос приведёт к тому, что придётся обращаться к основной базе данных.
В отличие от поиска в хранилище типа «ключ-значение», запросы к реляционной базе данных весьма ресурсоёмки. Велики шансы, что пакетам запросов / ответов понадобится пройти по гораздо большему числу узлов, возможно и то, что ответ понадобится обработать с использованием логики приложения, результаты нужно будет сохранить в кэше, и так далее.
Масштабирование хранилища типа «ключ-значение» выполняется быстро и недорого, чего нельзя сказать о масштабировании системы управления реляционными базами данных.
Если сервис использует пассивный кэш, тогда проблемы масштабирования им и ограничиваются. Архитектура с пассивным кэшем прямо-таки создана для быстрого и удобного увеличения мощности кэша. Однако, такая архитектура усложняет разработку.
Разработка сервисов, которые используют пассивный кэш
При создании сервиса, который использует пассивный кэш, нужно учитывать несколько требований.
- Во-первых, чтение данных можно выполнять только из кэша.
- Во-вторых, после каждой запрошенной у сервиса операции создания, обновления или удаления данных, он должен поставить соответствующую задачу в очередь запросов к хранилищу исходных данных.
- В-третьих, после каждой задачи на создание, обновление или удаление данных, выполненной над данными основного хранилища, сервис должен ставить в очередь задачу на обновление соответствующих участков кэша.
Каждую CRUD-операцию системы нужно реализовать с учётом вышеописанных ограничений.
В процессе разработки время между запросом на выполнение некоей операции и получением результата увеличивается из-за наличия очереди задач. Это замедляет процесс разработки и тестирования. Кроме того, программисту нужно знать о специфических ошибках, которые могут возникнуть из-за устаревших данных в кэше.
С другой стороны, в ходе разработки приложения, которое использует активный кэш, программист может, в ходе работы, попросту отключить кэш, добившись очень высокой скорости обработки произвольных запросов к основному хранилищу данных.
Разрабатывать системы, использующие пассивный кэш, сложнее, но, когда на первом месте — безопасность, обычно на подобные сложности внимания не обращают. Однако, это не значит, что все способы защиты от DDoS-атак непременно потребуют огромных усилий. Выше мы рассматривали пример атаки на службу блогов путём перебора идентификаторов статей. Смягчить последствия подобных атак можно, сделав идентификаторы ресурсов непредсказуемыми.
Подписывание идентификаторов ресурсов
Причина, по которой системы с активным кэшем подвержены вышеописанным атакам, заключается в том, что злоумышленник может легко сконструировать идентификатор ресурса. Независимо от того, является ли идентификатор числовым ID (таким, как в нашем примере), закодированным в base64 GUID, как в API GraphQL, или UUID, как в большинстве документ-ориентированных баз данных, проблема заключается в том, что, когда сервер получает запрос, ему неизвестно, существует ли запрошенный ресурс. Единственный способ это выяснить — выполнить обращение, либо к кэшу, либо к основному источнику данных, и дождаться ответа. Для того, чтобы сервер, ни к чему не обращаясь, смог бы определить, существует ли запрошенный ресурс, идентификаторы ресурсов можно подписать.
Подписывание позволяет, без серьёзного влияния на производительность системы, узнать, корректно ли сформирован запрос. Если идентификатор ресурса подписан, атакующий не сможет производить запросы по идентификаторам, не входящим в ограниченный набор общедоступных ID.
Работает всё это так: сервис получает запрос, и пытается расшифровать идентификатор ресурса. Если ему это удаётся, расшифрованное значение используется для поиска запрошенной записи. Если же идентификатор не может быть расшифрован — обработка запроса завершается.
Я использую такой подход при создании идентификаторов ресурсов GraphQL. В частности, прокси, который перенаправляет запросы GraphQL, предварительно проверяет, действителен ли ID ресурса.
SGUID
Signed GUID, или sguid — это пакт для Node.js, в который я вынес процедуры создания и проверки подписанных идентификаторов. Подписать идентификатор можно с помощью команды
toSguid
. Для проверки и открытия подписанных идентификаторов используется команда fromSguid
. Выглядит это так: import {
fromSguid,
InvalidSguidError,
toSguid,
} from 'sguid';
const secretKey = '6h2K+JuGfWTrs5Lxt+mJw9y5q+mXKCjiJgngIDWDFy23TWmjpfCnUBdO1fDzi6MxHMO2nTPazsnTcC2wuQrxVQ==';
const publicKey = 't01po6Xwp1AXTtXw84ujMRzDtp0z2s7J03AtsLkK8VU=';
const namespace = 'gajus';
const resourceTypeName = 'article';
const generateArticleSguid = (articleId: number): string => {
return toSguid(secretKey, namespace, resourceTypeName, articleId);
};
const parseArticleSguid = (articleGuide: string): id => {
try {
return fromSguid(publicKey, namespace, resourceTypeName, articleSguid).id;
} catch (error) {
if (error instanceof InvalidSguidError) {
// Handle error.
}
throw error;
}
};
В дополнение к подписыванию идентификаторов, Sguid рассчитан на использование пространств имён и идентификаторов типа ресурса. Это обеспечивает глобальную уникальность идентификаторов.
Sguid использует криптосистему с открытым ключом Ed25519. Получившаяся подпись кодируется с использованием URL-кодировки base64.
Минус такого подхода — идентификаторы, которые неудобно использовать людям:
pbp3h9nTr0wPboKaWrg_Q77KnZW1-rBkwzzYJ0Px9Qvbq0KQvcfuR2uCRCtijQYsX98g1F50k50x5YKiCgnPAnsiaWQiOjEsIm5hbWVzcGFjZSI6ImdhanVzIiwidHlwZSI6ImFydGljbGUifQ
Плюс — масштабируемая защита от DDoS-атак, проводимых на прикладном уровне модели OSI, без чрезмерного усложнения процесса разработки.
Итоги
Надо отметить, что описанная здесь методика совершенно не помогает защититься от атак, направленных на переполнение каналов связи. Более того, она эффективна только если кэш способен хранить данные для всех имеющих смысл запросов. Но, несмотря на подобные ограничения, это достойный внимания подход к защите от атак, рассчитанных на промахи серверного кэша.
Кроме того, надо помнить о том, что в деле защиты от кибератак важно то, как результаты нападения выглядят с точки зрения злоумышленника. Для того, чтобы, перебирая идентификаторы ресурсов, «пробить» эффективно сконструированную систему, использующую описанный здесь подход, понадобится серьёзная мощность. Возможно, атакующий просто не рассчитывает на подобное, и видя, что система на его действия не реагирует (хотя она вполне может работать на пределе возможностей), решит, что он уже испробовал всё, что можно, и прекратит атаку.
А как вы защищаетесь от DDoS-атак?