Ваш открытый API кто-то использует как бесплатный сервис
Вы делали 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 ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →