Разберите ошибку: API ограничивает число попыток входа, но работает в нескольких экземплярах. Какой архитектурный изъян позволяет обойти это ограничение?
счётчик = локальная_память["login:" + ip]
если счётчик >= 5:
вернуть 429
локальная_память["login:" + ip] = счётчик + 1
передать_проверку_пароля()
Ограничитель использует состояние, локальное для конкретного экземпляра сервиса. При балансировке запросов злоумышленник может распределить попытки между экземплярами и получить больше пяти попыток, чем предусмотрено политикой.
Счётчик нужно хранить в общем быстром хранилище либо применять другой механизм, обеспечивающий единое решение для всех экземпляров. Операция проверки и увеличения счётчика должна быть атомарной, иначе параллельные запросы также позволят превысить лимит.
Ограничение частоты запросов появилось как средство сдерживания перебора паролей, массового сбора данных и истощения ресурсов. В односерверных системах состояние часто хранили в памяти процесса, поскольку это просто и быстро.
С переходом к горизонтальному масштабированию один логический сервис стал состоять из нескольких экземпляров. Локальное состояние перестало представлять состояние всей системы, поэтому контроль, зависящий от общего счётчика, пришлось выносить за пределы отдельного процесса.
Балансировщик может направить последовательные запросы одного клиента на разные экземпляры. Каждый экземпляр видит только свою часть попыток и разрешает локальные первые пять запросов, хотя общий лимит уже превышен.
Даже при общем хранилище остаётся риск гонки: два экземпляра одновременно прочитают значение 4, оба решат, что лимит ещё не достигнут, и оба увеличат счётчик. В результате фактическое число разрешённых запросов превысит политику.
Ключ ограничения тоже важен. Лимит только по IP может блокировать общую сеть многих пользователей, а злоумышленник способен менять адреса. Для входа обычно дополнительно учитывают идентификатор учётной записи, но нельзя позволять атакующему создавать неограниченное число новых идентификаторов для обхода лимита.
Нужен общий компонент, например распределённое хранилище счётчиков, способное атомарно выполнить проверку и увеличение. Все экземпляры сервиса обращаются к нему с одним логическим ключом ограничения и одинаковыми правилами истечения срока.
Простейшая модель с фиксированным окном выглядит так:
Атомарность не позволяет двум параллельным запросам некорректно принять одно и то же устаревшее значение. На практике применяют Redis с атомарной операцией или Lua-скриптом, специализированный сервис ограничения частоты либо шлюз с распределённым хранилищем. Конкретный выбор зависит от требований к задержке, доступности и точности.
Фиксированное окно просто реализовать, но на границе двух окон оно допускает всплеск: клиент может выполнить почти полный лимит в конце одного окна и ещё полный лимит в начале следующего. Скользящее окно точнее, а token bucket позволяет контролируемую кратковременную очередность запросов и задаёт среднюю скорость.
Поведение при недоступности хранилища — отдельное решение безопасности. Режим «разрешить всё» сохраняет доступность, но может открыть путь к перебору; режим «запретить всё» защищает от перебора, но создаёт отказ входа при сбое компонента. Для критичного входа обычно задают ограниченный отказоустойчивый режим, мониторинг и отдельные лимиты для разных операций.
Ограничитель не заменяет блокировку учётной записи, многофакторную аутентификацию, проверку скомпрометированных паролей и журналирование. Он должен учитывать доверенную границу определения IP: заголовок от клиента нельзя считать адресом прокси без корректно настроенной цепочки доверенных прокси.
Сервис входа работал в восьми экземплярах за балансировщиком. Локальный лимит составлял пять попыток на IP за минуту, но скрипт распределял запросы по экземплярам и фактически выполнял до сорока попыток. Перенос счётчика в общее хранилище устранил обход, однако на пиковом трафике возникли задержки из-за синхронных обращений к нему.
Рассматривались три варианта. Лимит на балансировщике был быстрым, но не учитывал идентификатор учётной записи и усложнял разные политики для разных методов. Sticky-сессии сохраняли локальность, но не являлись надёжной гарантией: экземпляры могли перезапускаться, а маршрутизация могла измениться. Общий ограничитель дал единое решение, но добавил зависимость и сетевую задержку.
Выбрали общий ограничитель с атомарной операцией, отдельными ключами для IP и учётной записи, коротким временем ожидания и метриками отказов. Для неудачи хранилища применили заранее определённый безопасный режим для входа и алерты. В результате распределение запросов между экземплярами перестало влиять на лимит, а проблемы компонента стали наблюдаемыми.
Само общее расположение не гарантирует корректность. Если сервис выполняет отдельные операции чтения и записи, параллельные запросы могут потерять обновления. Нужны атомарная команда, транзакция с подходящей изоляцией или распределённый механизм блокировки; при этом блокировки должны иметь ограниченное время жизни, чтобы отказ экземпляра не оставил систему заблокированной навсегда.
Обычно комбинируют несколько измерений: учётную запись, источник запроса и иногда устройство или криптографически подтверждённый идентификатор клиента. Ограничение только по учётной записи защищает от распределённой атаки, но позволяет злоумышленнику перегружать конкретную запись; ограничение только по IP плохо работает за NAT и обходится через разные адреса. Политика должна учитывать риск ложной блокировки и не раскрывать существование учётной записи различиями в ответах.
Шлюз видит сетевой запрос, но может не знать бизнес-контекст: владельца ресурса, тип операции или результат аутентификации. Сервис находится ближе к защищаемому действию и может применить лимит к корректному субъекту и операции. Поэтому шлюз полезен как внешний слой защиты от массового трафика, а сервисный слой должен самостоятельно контролировать критичные действия, не полагаясь на единственную внешнюю границу.