Logo
Overview

Cloudflare 403 для LLM-скрипта: как лечить через nginx reverse proxy

May 18, 2026
8 min read

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 с чистой репутацией, прокидывает запрос в провайдера, и по дороге дописывает все правильные заголовки.

100%
graph LR
  A["Скрипт<br/>(python-requests)"] -->|POST<br/>без браузерных заголовков| B["nginx reverse proxy<br/>llm.example.com"]
  B -->|подменяет User-Agent,<br/>добавляет Accept-*| C["Cloudflare<br/>(WAF + CDN)"]
  C -->|пропускает,<br/>скоринг ОК| D["API провайдера<br/>api.provider.com"]
  D -->|200 OK + JSON| C
  C -->|200 OK| B
  B -->|200 OK| A
  E["Браузер разработчика"] -->|для сравнения| C

  style A fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style B fill:#50c878,stroke:#3a9a5c,color:#fff
  style C fill:#f0a500,stroke:#c88400,color:#fff
  style D fill:#7b68ee,stroke:#5a4db2,color:#fff
  style E fill:#e0e0e0,stroke:#999,color:#000

На схеме видно ключевую идею: 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, только в миниатюре.

Последовательность запроса целиком

Чтобы стало совсем понятно, что и в каком порядке происходит:

100%
sequenceDiagram
  participant S as Скрипт
  participant N as nginx
  participant CF as Cloudflare
  participant API as LLM API

  S->>N: POST /v1/messages<br/>User-Agent: python-httpx
  Note over N: подменяет заголовки,<br/>ставит Host, SNI
  N->>CF: POST /v1/messages<br/>User-Agent: Chrome/124
  Note over CF: проверяет UA, JA3,<br/>репутацию IP, скоринг
  alt всё ок
      CF->>API: проксирует запрос
      API-->>CF: 200 OK + JSON
      CF-->>N: 200 OK
      N-->>S: 200 OK
  else скоринг плохой
      CF-->>N: 403 Forbidden + HTML
      N-->>S: 403 Forbidden
      Note over S: смотрим cf-ray,<br/>идём чинить конфиг
  end

Сценарий «всё ок» — норма. Сценарий «скоринг плохой» — повод полезть в access-логи nginx, посмотреть, какие именно заголовки ушли в Cloudflare, и понять, какой ещё сигнал ему не понравился.

Типичные грабли

Перечислю те, на которые я и коллеги наступали лично — может, сэкономите себе вечер.

  1. Забыли proxy_ssl_server_name on. TLS-хендшейк проходит без SNI, Cloudflare не понимает, какой сайт нужен, кидает 403 или 525. Лечится одной строкой, ищется часами.
  2. Оставили Host: $host. В апстрим уходит ваш домен llm.example.com, провайдер вас не знает, Cloudflare шлёт «No Such App». Всегда явно ставим Host: api.provider.com.
  3. Не выключили proxy_buffering для стриминговых эндпоинтов. SSE-ответы копятся в буфере и отдаются клиенту порциями раз в минуту. Для /v1/messages со стримом обязательно proxy_buffering off.
  4. Маскируемся под устаревший Chrome. Поставили Chrome/98 пять лет назад — теперь сами выглядите как бот. Раз в полгода обновляйте User-Agent на актуальную версию.
  5. Прячем Authorization. Иногда люди настолько увлекаются «гигиеной заголовков», что выкидывают Authorization через proxy_set_header Authorization "". Без него провайдер вернёт 401, и вы будете долго думать, кто виноват.
  6. Положили nginx на тот же IP, что и скрипт. Если вся стойка живёт на одном /29 в дата-центре, репутация общая. Иногда помогает вынести nginx в другой регион или к другому провайдеру — да, грустно, но работает.

Заключение

Cloudflare и WAF-фильтры — это не зло, это компромисс. Они защищают провайдера от реального ботнета, и попутно ловят ваш интеграционный скрипт, потому что он выглядит как ботнет (а формально — он и есть автоматизированный клиент). Reverse proxy на nginx — это способ сказать «я свой» один раз и в одном месте, а не размазывать костыли по десяти репозиториям.

И да, через полгода Cloudflare обновит правила, ваш User-Agent снова перестанет работать, и придётся возвращаться к этой статье. Это не баг, это нормальная гонка вооружений между анти-бот системами и легитимными интеграциями, которые случайно попали под раздачу. На этот случай у инженеров есть хорошее правило — «работает не трогай», и парное к нему — «не работает, иди в логи». Cf-ray в access-логе nginx, кстати, и есть та самая нить, за которую можно потянуть, когда снова прилетит 403.