Атаки на цепочки поставок, подобные недавним инцидентам с Axios и LiteLLM, наглядно демонстрируют уязвимость, связанную с кодами доступа API. Стандартная рекомендация — немедленно заменить скомпрометированные ключи — имеет критический недостаток: она реагирует на угрозу, а не предотвращает её. Если злоумышленник получает ваш ключ, например, к OpenAI, в два часа ночи, он не будет ждать, пока вы проснётесь. Вместо этого запускаются автоматические запросы к вашим эндпоинтам, что приводит к исчерпанию кредитов и счету в тысячи долларов к утру. Эта уязвимость, обозначенная OWASP как LLM10:2025 (Неограниченное потребление или «Отказ кошелька»), требует проактивного подхода на уровне инфраструктуры.
Почему классическое ограничение частоты запросов не работает для LLM
Традиционные механизмы rate-limiting, которые контролируют количество запросов в единицу времени (например, 10 запросов в минуту), совершенно неэффективны в контексте языковых моделей. Проблема заключается в фундаментальном различии между запросом и его стоимостью.
Рассмотрим два условных запроса к LLM-сервису:
- Запрос 1: Короткое приветствие «Привет» (10 токенов). Стоимость: ~$0.0001.
- Запрос 2: Задача «Обобщи этот 50-страничный PDF» (30 000 токенов). Стоимость: ~$0.45.
Злоумышленнику для нанесения существенного финансового ущерба не требуется генерировать высокий трафик запросов. Достаточно отправить несколько, но дорогостоящих. Следовательно, необходима система бюджетного лимитирования (Budget Limiting), а не лимитирования по частоте. Эта система должна отслеживать не количество вызовов API, а совокупную сметную стоимость всех обработанных запросов для данного пользователя, сессии или ключа API, и блокировать дальнейшую обработку при достижении установленного порога.
Техническое препятствие: состояние гонки в stateless-архитектуре
Разработка такого решения для современного стека, например, для Next.js-приложений, развернутых на Vercel Edge, сталкивается с нетривиальными архитектурными сложностями. Функции Vercel Edge по своей природе не имеют состояния (stateless). Любая попытка отслеживать расходы пользователя в локальной переменной обречена на провал — данные исчезнут после завершения выполнения функции.
Использование стандартной базы данных для хранения состояния — возможный, но непрактичный путь. Сетевая задержка (latency) при каждом обращении к удалённой БД для проверки и обновления бюджета неприемлемо скажется на пользовательском опыте, сделав приложение медленным.
Однако главным «финальным боссом» является проблема состояния гонки (Race Condition). Представьте сценарий, когда пользователь или вредоносный скрипт отправляет 10 параллельных запросов к вашему Edge-приложению. Эти запросы могут быть обработаны разными, независимыми экземплярами функций:
- Экземпляр A проверяет текущий остаток бюджета в БД и видит: «Осталось: $0.05. Запрос допустим».
- Экземпляр B, работающий параллельно, выполняет ту же проверку и также видит остаток в $0.05.
- Оба экземпляра, получив «зелёный свет», выполняют дорогостоящие операции, каждая из которых оценивается, например, в $1.00.
- Каждый экземпляр затем пытается вычесть свою стоимость из бюджета.
Итог: система разрешила операции общей стоимостью $2.00 при фактическом бюджете в $0.05, что приводит к перерасходу в $1.95. Классическая схема «Проверить, затем обновить» (Check-then-Update) в распределённой среде не является атомарной и порождает эту уязвимость.
Решение: атомарные Lua-скрипты в Redis
Для гарантированно корректной работы в условиях высокой параллельной нагрузки необходимо объединить операции проверки доступного бюджета и его обновления в единую, неделимую (атомарную) операцию. Этого можно достичь, перенеся бизнес-логику лимитирования непосредственно в хранилище данных.
Элегантным решением является использование атомарных Lua-скриптов, выполняемых на стороне сервера Redis. Redis выполняет такой скрипт как единую команду, блокируя другие операции с задействованными ключами на время его выполнения, что полностью исключает состояние гонки.
Вот как работает логика «аварийного выключателя», реализованная в виде Lua-скрипта для Redis (например, через сервис Upstash, совместимый с serverless-средой):
-- Ключ для хранения текущих затрат пользователя (например, 'user_budget:{userId}')
local key = KEYS[1]
-- Установленный лимит бюджета (например, 1.00 USD)
local limit = tonumber(ARGV[1])
-- Предполагаемая стоимость текущего запроса
local cost = tonumber(ARGV[2])
-- Получаем текущие накопленные затраты. Если ключа нет, начинаем с 0.
local current = tonumber(redis.call('GET', key) or "0")
-- АТОМАРНАЯ ПРОВЕРКА И ОБНОВЛЕНИЕ
if (current + cost) <= limit then
-- Если бюджет не превышен, увеличиваем накопленную сумму.
redis.call('SET', key, current + cost)
-- Возвращаем успех и новый баланс.
return {true, current + cost}
else
-- Бюджет превышен. Запрос должен быть отклонён.
-- Возвращаем неудачу и текущий (неизменённый) баланс.
return {false, current}
end
Интеграция этого скрипта в Vercel Edge-функцию (или Middleware Next.js) выглядит следующим образом. Перед выполнением дорогостоящего вызова к LLM API приложение выполняет оценку токенов (через tiktoken или аналоги) и рассчитывает предполагаемую стоимость запроса. Затем оно вызывает атомарный скрипт в Redis, передавая идентификатор пользователя, лимит и стоимость. Скрипт, выполняемый атомарно, гарантирует, что решение «разрешить/запретить» и соответствующее обновление баланса производятся без вмешательства других параллельных процессов. Если скрипт возвращает false, приложение немедленно отклоняет запрос с соответствующим статусом HTTP (например, 429 Too Many Requests), активируя тем самым «аварийный выключатель».
Такой подход обеспечивает защиту от «отказа кошелька» с минимальными накладными расходами на задержку, соответствует требованиям serverless-архитектуры и предоставляет надёжный механизм контроля расходов в реальном времени.