MAATRIX / Блог / Ваш открытый API кто-то использует как бесплатный сервис

Ваш открытый API кто-то использует как бесплатный сервис

MAATRIX

Вы делали API для своего сайта или мобильного приложения и не поставили авторизацию — просто не было причины: свой фронтенд, свои клиенты, зачем усложнять. Через несколько месяцев сервер начинает тормозить в непонятные часы, а в логах обнаруживается трафик, который никогда не заходил на главную страницу. Кто-то нашёл ваш эндпоинт и построил на нём собственный сервис — бесплатно, за ваш счёт CPU, базы данных и исходящего трафика. Разберём, как это происходит, как это увидеть в логах и что сделать, чтобы закрыть дыру, не сломав легитимных клиентов.

Как открытый API попадает в чужие руки

Открытый API — это не обязательно ошибка проектирования. Часто это осознанное решение: если бэкенд обслуживает JavaScript в браузере пользователя, ключ API в этом JS всё равно виден каждому, кто откроет DevTools, — так что многие команды даже не пытаются прятать такие эндпоинты за секретом и полагаются на то, что «никто не будет искать». Проблема в том, что искать не обязательно должен человек.

Три типичных пути, которыми чужой API попадает в третьи руки:

  • Вкладка Network в браузере. Любой пользователь вашего сайта в одно нажатие смотрит, какие запросы уходят на бэкенд, какие у них параметры и заголовки. Если ответ отдаёт полезные данные — котировки, погоду, курс валют, каталог товаров, геокодирование — воспроизвести такой запрос в своём скрипте занимает пять минут.
  • Документация, оставленная открытой. Swagger/OpenAPI-схема на /api/docs или /swagger-ui, задуманная как внутренний инструмент для фронтенд-разработчиков, индексируется поисковиками и находится по прямому URL. Она не просто раскрывает существование API — она даёт готовый список эндпоинтов с параметрами, то есть экономит потенциальному потребителю всю работу по реверс-инжинирингу.
  • Случайная индексация. Если у эндпоинта GET-метод и он отдаёт валидный контент без проверки заголовков, поисковый краулер вполне может его проиндексировать — а дальше кто угодно найдёт URL через поиск. Это особенно бьёт по API вида /api/search?q=... или /api/generate-preview?url=..., где параметр — открытая форма и краулер с радостью подставляет туда что попало.

Дальше сценарий стандартный: кто-то встраивает ваш URL в свой продукт — виджет курса валют на стороннем сайте, Telegram-бот, дёргающий ваш геокодер, парсер, который раз в минуту опрашивает ваш каталог для агрегатора. Формально это не взлом — не подобран пароль, не эксплуатирована уязвимость, — но CPU, память бэкенда и исходящий трафик тратятся так же реально, как на своего клиента. Разница в том, что за этого «клиента» никто не платит и он не собирается прекращать нагрузку.

Как обнаружить неавторизованное использование: анализ трафика и логов

Прежде чем защищать API, нужно убедиться, что проблема вообще есть, и понять её масштаб. Три сигнала надёжно отличают чужого потребителя от вашего собственного фронтенда: заголовок Referer/Origin, User-Agent и распределение нагрузки во времени.

Referer и Origin. Браузер при запросе с вашей страницы отправляет заголовок Referer (или Origin для fetch/XHR) со значением вашего домена. Прямой вызов из скрипта — через curl, Python requests, серверный код на другом хостинге — либо не отправляет Referer вовсе, либо отправляет чужой домен. Чтобы это увидеть, Referer и User-Agent должны попадать в отдельный лог-формат:

log_format api_log '$remote_addr - $time_local "$request" $status '
                    '"$http_referer" "$http_user_agent" $request_time';
grep '/api/v1/quotes' /var/log/nginx/api_access.log \
  | awk -F'"' '{print $4}' \
  | sort | uniq -c | sort -rn | head -20

Если 40% запросов к /api/v1/quotes идут с Referer: - (пусто) или с чужого домена — это уже не ваш фронтенд.

User-Agent. Реальные браузеры отправляют развёрнутую строку вида Mozilla/5.0 (Windows NT 10.0; ...) Chrome/128.... Скрипты часто либо не меняют User-Agent по умолчанию (python-requests/2.32.3, curl/8.7.1, axios/1.7.2, Go-http-client/1.1), либо подделывают его под браузер — но тогда выдают себя отсутствием сопутствующих браузерных заголовков (Sec-Fetch-Mode, Accept-Language, cookie-сессии).

awk -F'"' '{print $6}' /var/log/nginx/api_access.log | sort | uniq -c | sort -rn | head -30

Библиотечные User-Agent в топе по частоте на эндпоинте, который должен вызываться только вашим SPA, — прямой признак стороннего потребителя.

Активность не через основной сайт. Сравните трафик на / (или на страницу, которая инициирует вызовы API) и трафик на сам API-эндпоинт за один и тот же интервал:

# запросов к главной странице за час
grep " /$ " /var/log/nginx/access.log | grep "$(date -d '1 hour ago' '+%d/%b/%Y:%H')" | wc -l

# запросов к API-эндпоинту за тот же час
grep "/api/v1/quotes" /var/log/nginx/api_access.log | grep "$(date -d '1 hour ago' '+%d/%b/%Y:%H')" | wc -l

Если на сайт заходит условно 200 человек в час, а API получает 40 000 запросов за тот же час — соотношение явно не сходится с тем, что каждый посетитель делает пару вызовов через свой браузер. Дополнительно полезно смотреть на регулярность: реальные пользователи создают трафик неравномерно, с паузами и пиками активности днём; скрипт, который опрашивает API раз в 5 секунд круглые сутки, включая ночные часы, — почти наверняка автоматизация. Общая методика чтения access log под другим углом — поиск следов SQL-инъекций — разобрана в статье SQL-инъекция глазами логов: что видно в access log, приёмы агрегации там те же.

Стоит также сгруппировать нагрузку по IP и подсетям — один потребитель редко бьёт с одного статичного адреса долго, но за первые дни обнаружения это всё равно самый быстрый способ увидеть источник:

grep "/api/v1/quotes" /var/log/nginx/api_access.log \
  | awk '{print $1}' | sort | uniq -c | sort -rn | head -20

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

API-ключи: базовая защита, которая закрывает большинство случаев

Самый простой и при этом самый эффективный барьер — обязательный API-ключ в заголовке. Он не защищает от целенаправленного и технически подкованного злоумышленника (ключ можно вытащить из клиентского JS так же, как раньше вытаскивался URL), но полностью убирает случайных потребителей — тех, кто просто скопировал URL из DevTools и не собирается разбираться дальше, и краулеров, которые индексируют открытые GET-эндпоинты.

Схема на уровне nginx без изменения бэкенда — простая проверка статического набора ключей:

map $http_x_api_key $api_client_valid {
    default        0;
    "sk_live_a1b2" 1;
    "sk_live_c3d4" 1;
}

server {
    location /api/ {
        if ($api_client_valid = 0) {
            return 401;
        }
        proxy_pass http://backend;
    }
}

Для реальной системы лучше вынести проверку в бэкенд — там можно привязать ключ к клиенту, считать использование и отзывать ключи без перезагрузки nginx. Минимальная модель на уровне мидлвари (псевдокод, применим к Express/Fastify/Flask одинаково):

async function requireApiKey(req, res, next) {
  const key = req.header('X-API-Key');
  if (!key) return res.status(401).json({ error: 'missing_api_key' });

  const client = await db.apiKeys.findOne({ key, active: true });
  if (!client) return res.status(403).json({ error: 'invalid_api_key' });

  req.apiClient = client; // пригодится для rate limiting по ключу
  next();
}

Важный нюанс: если ваш собственный фронтенд — это браузерный SPA без бэкенда-посредника, статический ключ в JS-бандле всё равно виден всем. В этом случае ключ решает задачу отсечения ботов и случайных копипастеров, но не задачу «спрятать секрет от целеустремлённого человека» — для этого нужен серверный прокси-слой, который держит настоящий ключ у себя и не отдаёт его в браузер.

Rate limiting по ключу вместо IP

Ограничение по IP — стандартная защита от перегрузки, но у неё есть слепое пятно именно для этой задачи: у стороннего потребителя, скрывающегося за облачным прокси или ротацией адресов, IP меняется, а у офиса с NAT — наоборот, десятки легитимных пользователей делят один IP и упираются в общий лимит. Подробно эта проблема разобрана в статье Как работает rate limiting и почему честные клиенты страдают первыми — если у вас ещё нет базового лимитирования, начните с неё.

Когда есть API-ключи, логичнее считать лимит по ключу, а не по IP — тогда каждый клиент получает свою честную квоту независимо от того, сколько у него реальных адресов:

limit_req_zone $http_x_api_key zone=per_key:10m rate=20r/s;

server {
    location /api/ {
        if ($api_client_valid = 0) {
            return 401;
        }
        limit_req zone=per_key burst=40 nodelay;
        proxy_pass http://backend;
    }
}

Здесь ключ учёта — не IP, а значение заголовка X-API-Key, поэтому лимит держится за конкретным клиентом вне зависимости от того, сколько у него исходящих адресов. Для запросов без ключа (если у вас часть эндпоинтов всё же остаётся публичной) отдельная, более жёсткая зона по IP:

limit_req_zone $binary_remote_addr zone=anonymous:10m rate=2r/s;

location /api/public/ {
    limit_req zone=anonymous burst=5 nodelay;
    proxy_pass http://backend;
}

Разные лимиты для авторизованных и анонимных запросов — это и есть практический баланс: свои клиенты с ключом получают нормальную квоту, всё остальное искусственно прижато к минимуму, достаточному для одиночных ручных запросов, но неудобному для промышленного парсинга.

Отдельно стоит завести fail2ban-правило на массовые 401/403 от одного источника — это отсекает попытки перебора ключей и грубый скан эндпоинтов ботами, которые даже не пытаются притвориться легитимным клиентом. Готовый набор правил под nginx есть в статье fail2ban для nginx: настройка правил.

CORS для браузерных вызовов и его реальные пределы

CORS (Cross-Origin Resource Sharing) — механизм, который браузер применяет сам, без участия сервера: если ваш JS с example.com пытается сделать fetch-запрос на api.example.com, а тот не прислал заголовок Access-Control-Allow-Origin с разрешением для example.com, браузер заблокирует ответ на уровне самого браузера, до того как JS-код его увидит.

location /api/ {
    add_header Access-Control-Allow-Origin "https://example.com" always;
    add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
    add_header Access-Control-Allow-Headers "X-API-Key, Content-Type" always;

    if ($request_method = OPTIONS) {
        return 204;
    }

    proxy_pass http://backend;
}

Важно понимать, что именно защищает CORS, а что нет. Он мешает чужому сайту в браузере другого пользователя выполнить AJAX-запрос к вашему API от его имени и прочитать ответ — это защита от злоупотребления чужой браузерной сессией. CORS не помогает, если запрос идёт не из браузера, а с сервера — через curl, Python-скрипт, Postman: там нет движка, проверяющего эти заголовки, они просто игнорируются. Не помогает он и когда у чужого потребителя есть свой бэкенд-прокси: он делает запрос к вашему API со своего сервера и отдаёт результат уже в свой браузер — с точки зрения вашего API это server-to-server вызов, и правило CORS к нему не применяется вовсе.

Поэтому CORS — это защита конкретно браузерного сценария (чужой сайт ворует вызовы через сессию вашего пользователя), а не универсальный барьер против внешнего потребления API. Он должен идти вместе с ключами и лимитами, а не вместо них.

Баланс между открытостью и защитой

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

Тип эндпоинтаНужна защитаПодход
Внутренний, обслуживает только ваш SPA/приложениеДа, минимум ключ + лимитКлюч в заголовке, лимит по ключу, CORS на свой домен
Публичный API как продукт (документированный, с тарифами)Да, полноценноКлючи с тарифными планами, лимиты по уровню тарифа, отдельная документация с честным описанием квот
Действительно открытые данные (например, статус сервиса, публичный каталог без персонализации)МинимальноМягкий лимит по IP, кеширование ответа, чтобы повторные запросы не били по бэкенду
Эндпоинт с дорогой операцией (генерация, поиск, вызов стороннего платного API)Да, строгоКлюч обязателен, лимит жёсткий, отдельный мониторинг стоимости на клиента

Последняя строка — самая частая причина реальных финансовых потерь. Если ваш эндпоинт внутри дёргает платный сторонний API (геокодирование, LLM, SMS-шлюз), каждый чужой запрос — это не абстрактная нагрузка на CPU, а прямая строка в чужом счёте. Оценить реальную стоимость одного запроса, чтобы понимать масштаб риска, помогает статья Стоимость одного API-запроса: методика расчёта — зная цену запроса, проще аргументировать перед командой, почему на эндпоинт нужен ключ уже сейчас, а не «когда-нибудь потом».

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

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Можно ли обойтись только CORS и не заводить API-ключи?

Нет, если эндпоинт дорогой или отдаёт неанонимные данные. CORS блокирует только браузерные cross-origin запросы через fetch/XHR, а прямые вызовы с сервера, через curl или через прокси на чужой стороне он не видит вообще.

Ключ в клиентском JS-бандле — это не то же самое, что просто оставить API открытым?

Не совсем. Статический ключ в бандле виден любому, кто откроет DevTools, — так же, как раньше был виден URL. Но он всё равно отсекает автоматических потребителей (краулеров, простых копипастеров URL) и даёт возможность отозвать конкретный ключ и лимитировать его отдельно, чего нет у просто открытого эндпоинта.

Как отличить рост от нормальных пользователей от постороннего потребления, если у меня нет истории трафика для сравнения?

Смотрите соотношение запросов к странице, которая инициирует вызовы API, и запросов к самому API за один интервал времени. Если один пользователь браузера физически не может сгенерировать такое количество вызовов за разумное время взаимодействия со страницей — расхождение и есть сигнал.

Что делать, если чужой потребитель уже найден и определён его IP или диапазон?

Краткосрочно — заблокировать по IP или через fail2ban-правило на статус-коды. Но это временная мера: тот, кто нашёл открытый эндпоинт один раз, может сменить IP или маршрутизировать через прокси. Постоянное решение — ключи и лимиты, а не бан конкретного адреса.

Нужно ли скрывать документацию API (Swagger/OpenAPI), если она не предназначена для внешних пользователей?

Да, если API не публичный продукт. Закройте /api/docs и подобные пути тем же ключевым механизмом или базовой HTTP-авторизацией, и убедитесь, что путь не проиндексирован — проверьте через site:ваш-домен /swagger в поиске.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →