Cloudflare 403 для LLM-скрипта: как лечить через nginx reverse proxy
Пятница, вечер. Скрипт, который вчера спокойно ходил в LLM-провайдера и тянул ответы, сегодня внезапно отвечает 403 Forbidden. В логах — ни ошибки авторизации, ни 401, ни намёка на тариф. Просто стена. Telegram-чат с командой превращается в детектив, кто-то говорит «у меня всё работает», и это самое подозрительное, что можно услышать в пятницу.
Если такое случалось — почти наверняка между вашим скриптом и LLM-провайдером сидит Cloudflare, и он только что решил, что вы — бот. Хорошая новость: чинится. Плохая — лечение похоже на маскировку под человека, а не на «правильное» инженерное решение. В этом посте разберём, почему именно Cloudflare банит ваш интеграционный скрипт, какие заголовки и фингерпринты он смотрит, и как поставить промежуточный nginx, который превратит «голый» запрос в «как будто это был curl из браузера». Если вы только начинаете разбираться с тем, что такое API-слой и зачем перед ним нужен прокси, имеет смысл сначала пробежать основы REST API и почитать про API Gateway — без этого контекста часть решений ниже будет звучать как «магия».
Что на самом деле возвращает 403
Первое, что важно понять: 403 Forbidden от Cloudflare и 403 Forbidden от самого приложения — это два разных мира. Приложение говорит «у тебя нет прав». Cloudflare говорит «я тебя не пустил даже до приложения».
Cloudflare — CDN и WAF-провайдер, который сидит перед сайтом провайдера и фильтрует трафик. Когда он отвечает 403, до бэкенда LLM-провайдера запрос даже не дошёл.
Различить просто. Посмотрите ответ внимательнее:
- Заголовок
Server: cloudflare— это он. - Заголовок
cf-ray: 8a7f...-FRA— это его трейс-айди, по нему саппорт ищет блок. - Тело — HTML с надписью вроде «Sorry, you have been blocked» или Just a moment…
Если вы видите эти три признака — ругаться на провайдера бессмысленно, провайдер тут не при чём (а если и при чём, то узнает он об этом последним). Виноват промежуточный слой, и чинить надо со своей стороны.
Почему именно ваш скрипт
Cloudflare смотрит не на «кто вы по токену», а на «как вы выглядите по запросу». Анализируется коктейль из нескольких сигналов:
- User-Agent. Если в нём
python-requests/2.31.0,Go-http-client/1.1или (особенно весело) пустая строка — это красная тряпка. - Отсутствие браузерных заголовков. Настоящий браузер шлёт
Accept,Accept-Language,Accept-Encoding,sec-ch-ua,sec-fetch-*. Голыйrequests.post(url, json=...)не шлёт почти ничего. - TLS-фингерпринт (JA3/JA4). Это уже про то, как ваш TLS-клиент здоровается с сервером: порядок шифров, набор расширений. У
requestsи у Chrome он принципиально разный. - Поведенческие признаки. Слишком быстро, слишком ритмично, всегда из одной /24-подсети, всегда без cookies — суммируется в risk score.
- Репутация IP. Дата-центровые IP (AWS, Hetzner, Selectel, OVH) у Cloudflare заведомо «грязнее», чем домашние.
Каждый сигнал по отдельности — мелочь. Сложите три-четыре, и Cloudflare уверенно ставит вам 403, не задавая вопросов. Что характерно — в браузере с того же ноутбука всё открывается, потому что там «нормальный» User-Agent, cookies, корректный TLS-фингерпринт.
Минимальное лечение: чиним User-Agent
Прежде чем городить reverse proxy, попробуйте самое тупое — выставить нормальный User-Agent прямо в клиенте.
import httpx
headers = { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", "Accept": "application/json, text/plain, */*", "Accept-Language": "en-US,en;q=0.9", "Accept-Encoding": "gzip, deflate, br",}resp = httpx.post(url, headers=headers, json=payload, timeout=60)В половине случаев этого хватает — Cloudflare успокаивается, и 403 уходит. Если не помогло, значит сработал TLS-фингерпринт или поведенческие правила, и тут уже клиент-сайдом не отделаться.
Важно: подменять User-Agent на «как у Chrome» — это не взлом и не нарушение, если вы ходите в API, который сами оплачиваете по своему ключу. Это компенсация того, что Cloudflare применяет фильтры, рассчитанные на анонимный браузерный трафик, к вашему легитимному API-вызову. Но если в ToS провайдера прямо запрещено маскироваться — читайте ToS, а не этот пост.
Решение через nginx reverse proxy
Когда правкой клиента не отделаться (а ещё чаще — когда клиентов десять, и править каждый дорого), ставят перед ними промежуточный nginx. Идея простая: ваши скрипты ходят не в api.provider.com, а в свой llm.example.com, который физически стоит на нормальном IP с чистой репутацией, прокидывает запрос в провайдера, и по дороге дописывает все правильные заголовки.
На схеме видно ключевую идею: nginx — не просто транзит, а слой, который причёсывает запрос до того, как он встретит Cloudflare. Скрипт остаётся максимально тупым, вся логика «как притвориться браузером» живёт в одном месте.
Минимальный конфиг nginx
server { listen 443 ssl http2; server_name llm.example.com;
ssl_certificate /etc/letsencrypt/live/llm.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/llm.example.com/privkey.pem;
location / { proxy_pass https://api.provider.com; proxy_ssl_server_name on; proxy_ssl_name api.provider.com; proxy_set_header Host api.provider.com;
proxy_set_header User-Agent "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; proxy_set_header Accept "application/json, text/plain, */*"; proxy_set_header Accept-Language "en-US,en;q=0.9"; proxy_set_header Accept-Encoding "gzip, deflate, br";
proxy_http_version 1.1; proxy_set_header Connection "";
proxy_read_timeout 300s; proxy_send_timeout 300s;
proxy_pass_request_headers on; }}Разберём построчно, что тут важно:
proxy_ssl_server_name on+proxy_ssl_name— обязательно для SNI. Без них Cloudflare не поймёт, какой именно сайт вы запрашиваете, и вернёт 403 ещё на TLS-уровне.proxy_set_header Host— Cloudflare матчит по хосту, не по IP. Если оставитьHost: llm.example.com, провайдер не узнает в вас своего клиента.- Подмена
User-AgentиAccept-*— то самое маскирование, ради которого вся стройка и затевалась. proxy_http_version 1.1+Connection ""— нужно для keep-alive и стриминга. Без этого SSE-ответы LLM будут резаться.
Что nginx чинит, а что — нет
Важно понимать границы. Reverse proxy лечит заголовочные и репутационные проблемы:
| Проблема | Чинит ли nginx |
|---|---|
| Кривой User-Agent | Да, подменой заголовка |
Отсутствие Accept-* | Да, добавлением |
| Плохая репутация IP скрипта | Да, если nginx на чистом IP |
| Кривой TLS-фингерпринт клиента | Да, потому что TLS-хендшейк с Cloudflare делает nginx, а не ваш скрипт |
| Капча Cloudflare Turnstile | Нет, это решается только в браузере |
| JS-челлендж (Just a moment…) | Нет, нужен headless-браузер |
| Жёсткий бан по ASN | Частично — поможет только смена хостинга |
Если вы упёрлись в Turnstile или JS-челлендж — это уже не «API-скрипт против WAF», это «бот-фронтенд против анти-бот системы», и там другой класс решений (Playwright, undetected-chromedriver, специализированные сервисы). Reverse proxy эту проблему не решает.
Сравнение подходов
| Подход | Сложность | Что лечит | Когда выбирать |
|---|---|---|---|
Подмена User-Agent в клиенте | 5 минут | Простые WAF-правила | Один скрипт, временное решение |
| nginx reverse proxy | Час на настройку | UA, Accept-*, IP-репутацию, TLS | Много клиентов, продакшен |
Cloudflare-bypass библиотеки (cloudscraper, curl_cffi) | Полчаса | TLS-фингерпринт без своей инфры | Один-два скрипта, не хочется поднимать nginx |
| Headless-браузер (Playwright) | День | JS-челленджи, Turnstile | Скрейпинг сайтов, не API |
| Платный bypass-сервис | Деньги | Всё, но платно | Когда время дороже денег |
Для типичной задачи «у меня много скриптов ходят в LLM-провайдера и иногда ловят 403» правильный ответ — nginx. Он становится единой точкой контроля: меняется поведение Cloudflare, правится один конфиг, перезагружается один процесс. Все остальные скрипты даже не узнают, что что-то поменялось — ровно тот же подход, что и в API Gateway, только в миниатюре.
Последовательность запроса целиком
Чтобы стало совсем понятно, что и в каком порядке происходит:
Сценарий «всё ок» — норма. Сценарий «скоринг плохой» — повод полезть в access-логи nginx, посмотреть, какие именно заголовки ушли в Cloudflare, и понять, какой ещё сигнал ему не понравился.
Типичные грабли
Перечислю те, на которые я и коллеги наступали лично — может, сэкономите себе вечер.
- Забыли
proxy_ssl_server_name on. TLS-хендшейк проходит без SNI, Cloudflare не понимает, какой сайт нужен, кидает 403 или 525. Лечится одной строкой, ищется часами. - Оставили
Host: $host. В апстрим уходит ваш доменllm.example.com, провайдер вас не знает, Cloudflare шлёт «No Such App». Всегда явно ставимHost: api.provider.com. - Не выключили
proxy_bufferingдля стриминговых эндпоинтов. SSE-ответы копятся в буфере и отдаются клиенту порциями раз в минуту. Для/v1/messagesсо стримом обязательноproxy_buffering off. - Маскируемся под устаревший Chrome. Поставили
Chrome/98пять лет назад — теперь сами выглядите как бот. Раз в полгода обновляйтеUser-Agentна актуальную версию. - Прячем
Authorization. Иногда люди настолько увлекаются «гигиеной заголовков», что выкидываютAuthorizationчерезproxy_set_header Authorization "". Без него провайдер вернёт 401, и вы будете долго думать, кто виноват. - Положили nginx на тот же IP, что и скрипт. Если вся стойка живёт на одном /29 в дата-центре, репутация общая. Иногда помогает вынести nginx в другой регион или к другому провайдеру — да, грустно, но работает.
Заключение
Cloudflare и WAF-фильтры — это не зло, это компромисс. Они защищают провайдера от реального ботнета, и попутно ловят ваш интеграционный скрипт, потому что он выглядит как ботнет (а формально — он и есть автоматизированный клиент). Reverse proxy на nginx — это способ сказать «я свой» один раз и в одном месте, а не размазывать костыли по десяти репозиториям.
И да, через полгода Cloudflare обновит правила, ваш User-Agent снова перестанет работать, и придётся возвращаться к этой статье. Это не баг, это нормальная гонка вооружений между анти-бот системами и легитимными интеграциями, которые случайно попали под раздачу. На этот случай у инженеров есть хорошее правило — «работает не трогай», и парное к нему — «не работает, иди в логи». Cf-ray в access-логе nginx, кстати, и есть та самая нить, за которую можно потянуть, когда снова прилетит 403.