Теневые запросы: Защищаем API-ключ от фронтенд-утечек

19.06.2026 13:00

Утро вторника началось с воя пейджера и письма от GitGuardian: «В вашем публичном репозитории обнаружен API-ключ». Следом прилетело уведомление от биллинга — за ночь неизвестные скрипты выжгли тысячу долларов на запросах к дорогим моделям. Причина банальна: разработчики выкатили новую фичу с ИИ-ассистентом и зашили ключ от RouterAPI прямо в React-приложение.

«Мы же положили его в .env и добавили префикс VITE_!» — оправдывался мидл на ретроспективе.

Это классическая ошибка. Сборщики вроде Vite или Webpack честно подставляют значения переменных окружения в минифицированный JavaScript-бандл во время сборки. Ваш секретный ключ улетает на CDN, оседает в кэшах браузеров и становится доступен любому парсеру, сканирующему интернет на предмет забытых кредов. Даже если злоумышленник не читает исходники, ему достаточно открыть вкладку Network в DevTools, скопировать заголовок Authorization: Bearer sk-.. из исходящего XHR-запроса и вставить его в свой скрипт.

Браузер — враждебная среда. Любой секрет, оказавшийся на клиенте, перестает быть секретом в ту же секунду. Обфускация кода лишь оттягивает неизбежное на пару минут.

Анатомия фронтенд-утечек в SPA

Single Page Applications (SPA) и мобильные клиенты не умеют хранить тайны. Архитектура толстого клиента подразумевает, что весь исполняемый код находится в руках пользователя. Когда вы делаете прямой запрос к стороннему API (например, к OpenAI или RouterAPI) с фронтенда, вы вынуждены передавать токен аутентификации.

Последствия утечки ключа от нейросетевого шлюза катастрофичны. В отличие от ключей к картам или аналитике, где ущерб ограничен порчей или кражей некритичных данных, утекший ключ от LLM-провайдера — это прямой доступ к вашим деньгам. Злоумышленники моментально интегрируют валидные ключи в свои фермы ботов, генераторы дорвеев или продают доступ на теневых форумах. Баланс улетает в ноль за часы.

Архитектурный сдвиг: Теневые запросы

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

Бэкенд выступает в роли непрозрачного прокси-сервера. Он принимает запрос от клиента, проверяет права доступа, подмешивает секретный API-ключ на своей стороне и отправляет «теневой» запрос к провайдеру. Клиент получает только финальный результат.

Этот паттерн решает сразу три задачи:

  1. Изоляция секретов. Ключ живет только в памяти бэкенд-сервера и никогда не пересекает границу сети в сторону пользователя.
  2. Контроль доступа. Вы сами решаете, кто, когда и сколько запросов может сделать.
  3. Обогащение контекста. Бэкенд может незаметно для пользователя добавлять системные промпты, RAG-контекст или фильтровать запрещенные темы до того, как запрос уйдет к LLM.

Интеграция с RouterAPI: Настраиваем безопасный прокси

Рассмотрим реализацию на базе PHP (Laravel), так как именно этот стек часто выступает связующим звеном в enterprise-системах. Главная техническая сложность при проксировании LLM-запросов — поддержка потоковой передачи (Server-Sent Events, SSE). Если вы просто дождетесь полного ответа от RouterAPI и только потом отдадите его клиенту, Time-To-First-Byte (TTFB) вырастет до десятков секунд. Пользователь решит, что приложение зависло.

Нам нужно читать поток от RouterAPI и моментально транслировать его во фронтенд.

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use GuzzleHttp\Client;
use Symfony\Component\HttpFoundation\StreamedResponse;

class AiProxyController extends Controller
{
 public function streamChat(Request $request)
 {
 // 1. Валидация входящих данных от нашего фронтенда
 $validated = $request->validate([
 'messages' => 'required|array',
 'model' => 'required|string',
 ]);

 // 2. Проверка авторизации и лимитов (Rate Limiting)
 $user = $request->user;
 if (!$user->canMakeAiRequest) {
 abort(429, 'Превышен лимит запросов');
 }

 return new StreamedResponse(function use ($validated) {
 $client = new Client;
 
 // 3. Формируем теневой запрос к RouterAPI
 $response = $client->request('POST', config('services.routerapi.endpoint'), [
 'headers' => [
 'Authorization' => 'Bearer ' . config('services.routerapi.key'),
 'Content-Type' => 'application/json',
 'Accept' => 'text/event-stream',
 ],
 'json' => [
 'model' => $validated['model'],
 'messages' => $validated['messages'],
 'stream' => true,
 ],
 'stream' => true, // Включаем потоковое чтение в Guzzle
 ]);

 $body = $response->getBody;

 // 4. Транслируем чанки данных клиенту по мере их поступления
 while (!$body->eof) {
 echo $body->read(1024);
 
 // Прерываем чтение, если клиент закрыл соединение
 if (connection_aborted) {
 break;
 }
 
 ob_flush;
 flush;
 }
 }, 200, [
 'Cache-Control' => 'no-cache',
 'Content-Type' => 'text/event-stream',
 'X-Accel-Buffering' => 'no', // Отключаем буферизацию Nginx
 ]);
 }
}

Разбор механики проксирования

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

Во-первых, заголовок X-Accel-Buffering: no. Nginx по умолчанию буферизует ответы бэкенда. Он будет копить чанки от RouterAPI в памяти и отдаст их клиенту только когда буфер заполнится или соединение закроется. Это полностью убивает идею стриминга. Отключение буферизации заставляет Nginx пробрасывать байты клиенту мгновенно.

Во-вторых, connection_aborted. Если пользователь закрыл вкладку браузера до завершения генерации, нет смысла продолжать жечь токены. Бэкенд должен обнаружить обрыв соединения и прервать чтение потока от провайдера.

Защита самого прокси-эндпоинта

Убрав ключ с фронтенда, мы закрыли уязвимость нулевого дня, но создали новую точку отказа. Теперь наш собственный эндпоинт /api/ai/chat торчит наружу. Если его не защитить, злоумышленник напишет скрипт, который будет дергать уже наш бэкенд, истощая баланс RouterAPI.

Прокси-эндпоинт требует жесткой эшелонированной защиты:

  1. Аутентификация. Запрос к прокси должен сопровождаться валидной сессией (Cookie) или JWT-токеном вашего приложения. Анонимный доступ недопустим.
  2. Rate Limiting. Ограничьте количество запросов. Например, не более 5 сообщений в минуту с одного IP-адреса или аккаунта. В Laravel это решается через middleware throttle:5,1.
  3. Квотирование (Биллинг). Привяжите расходы к конкретному пользователю. Считайте токены и списывайте их с внутреннего баланса клиента. Если внутренний баланс пуст — блокируйте запрос до обращения к RouterAPI.
  4. Валидация Payload'а. Не позволяйте фронтенду передавать произвольные параметры. Жестко фиксируйте max_tokens, temperature и список разрешенных моделей на бэкенде. Клиент должен передавать только текст сообщения.

Резюме

Оставлять API-ключи в клиентском коде — это техническое самоубийство. Рано или поздно бандл декомпилируют, трафик перехватят, а ключ улетит в паблик.

Перенос логики взаимодействия с RouterAPI на бэкенд требует дополнительных инженерных усилий: настройки потоковой передачи, управления обрывами соединений и защиты прокси-эндпоинта. Но это единственная архитектура, которая гарантирует сохранность бюджета и контроль над инфраструктурой. Бэкенд должен оставаться единственным доверенным шлюзом между вашими пользователями и платными нейросетевыми API.

Теги

Ещё по теме