В моём проекте uo-request-generator генерация заявки обращается к внешней LLM. Перед этим приложение проверяет ограничения частоты запросов. Одно из них — не более трёх допущенных попыток за скользящие 60 секунд с одного IP-адреса.1

Но корректно считать запросы недостаточно. Нужно ещё определить, по какому адресу их считать. Если клиент может сам выбирать этот адрес, ограничитель будет исправно обслуживать всё новые счётчики.

В первой реализации проекта доверие к прокси включалось булевой переменной. Ограничение такого подхода было прямо отмечено в PR: включённый режим доверяет заголовкам от любого источника, который способен достучаться до приложения. Позже его заменили явным списком доверенных адресов и сетей.2

У приложения есть адрес соединения и адрес из заголовка

Рассмотрим обычную схему с обратным прокси-сервером:

клиент → прокси → приложение

При таком HTTP-проксировании соединение с приложением устанавливает прокси. Поэтому адрес непосредственного сетевого соседа и адрес исходного клиента — разные значения. В Fastify результат определения клиентского адреса доступен через request.ip; его поведение зависит от настройки доверия к прокси.3

Чтобы передать адрес клиента, прокси может использовать X-Forwarded-For. Но такой заголовок способен прислать и сам клиент. Его наличие не доказывает, что значение сформировал доверенный участник инфраструктуры. Fastify отдельно предупреждает о возможности подделки этих метаданных.4

Здесь возможны две противоположные ошибки.

Если совсем не учитывать прокси, запросы разных клиентов могут попасть в общую квоту его адреса. Если безусловно доверять присланному заголовку, клиент может получить возможность менять ключ ограничения.

Поэтому вопрос не в том, читать ли X-Forwarded-For вообще. Вопрос — от кого приложение готово принимать сведения об исходном адресе.

trustProxy: true означает больше, чем «перед нами стоит прокси»

В Fastify значение trustProxy: true означает доверие всем прокси, а не проверку того, что запрос действительно прошёл через конкретный сервер. Вместо него можно задать список IP-адресов и сетей CIDR.4

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

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

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

Вместо общего доверия — явный список

В uo-request-generator переменную GENERATION_TRUST_PROXY заменили на GENERATION_TRUSTED_PROXIES. Новая настройка принимает буквальные IPv4, IPv6 и сети CIDR. После проверки конфигурации список передаётся штатному механизму Fastify.5

Ключевая часть настройки выглядит так:

const app = Fastify({
  trustProxy:
    generationRateLimitConfig.trustedProxies.length === 0
      ? false
      : [...generationRateLimitConfig.trustedProxies],
});

Это фрагмент конфигурации приложения, без не относящихся к доверию параметров. Собственный разбор X-Forwarded-For здесь не нужен.6

В список входят прокси, которым разрешено сообщать адрес клиента, а не сами клиенты. Нужно учитывать адреса, с которых соединения действительно приходят к приложению. Публичный адрес балансировщика не обязательно совпадает с адресом его соединения с backend.

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

Отсутствующая настройка и ошибочная настройка — разные состояния

Без GENERATION_TRUSTED_PROXIES приложение запускается с trustProxy: false. Но пустое значение, некорректный адрес, имя хоста или оставленная старая переменная останавливают запуск. Ошибочная конфигурация не превращается в молчаливое разрешение доверять всем.5

За прокси отсутствие настройки всё ещё может привести к общей квоте для его адреса. Значение по умолчанию не угадывает топологию: оно лишь не делегирует определение клиентского IP неизвестному источнику.

Доверенный прокси должен правильно формировать заголовок

Список доверенных адресов отвечает на вопрос, кто передал сведения. Он не исправляет поведение самого прокси.

Для простой схемы, где Nginx непосредственно принимает клиентское соединение, можно перезаписывать входящий заголовок:

proxy_set_header X-Forwarded-For $remote_addr;

Это пример настройки передачи адреса, а не полная конфигурация сервера. В такой схеме значение берётся из адреса клиента, который видит Nginx, вместо сохранения присланного клиентом X-Forwarded-For. Директива proxy_set_header позволяет явно переопределять заголовки запроса к приложению.7

У примера есть существенное условие: перед Nginx нет ещё одного прокси, а его клиентский адрес не был переопределён через недоверенные данные. Модуль Nginx Real IP умеет менять этот адрес и требует собственной настройки доверенных источников.8

Для цепочки из нескольких прокси нельзя механически повторить ту же настройку на каждом узле: так можно потерять исходный адрес.

Другой распространённый вариант — $proxy_add_x_forwarded_for. Он сохраняет входящий X-Forwarded-For и дописывает к нему $remote_addr. Это не очищенная история запроса: её начало может содержать значение, присланное клиентом.7

Допустим, в условном примере приложение получает:

адрес соединения: 192.0.2.10
X-Forwarded-For: 203.0.113.77, 198.51.100.23

Все адреса в этом примере условные, не из рабочей инфраструктуры. Приложение доверяет прокси 192.0.2.10. Прокси дописал наблюдаемый адрес клиента 198.51.100.23. Значение 203.0.113.77 клиент прислал сам.

Механизм @fastify/proxy-addr рассматривает адреса от ближайшего к приложению и возвращает ближайший недоверенный адрес. В этом примере им будет 198.51.100.23: оснований продолжать доверенный обход до 203.0.113.77 нет. Поэтому простое чтение первого элемента заголовка не заменяет проверку цепочки.9

Перезапись на внешней границе и разбор цепочки — разные способы организации передачи адреса. Выбор зависит от топологии. Общим остаётся требование: клиент не должен управлять значением, которое приложение принимает за достоверный источник запроса.

Список доверенных прокси не закрывает сетевой доступ

Настройка trustProxy определяет интерпретацию метаданных запроса. Она не является правилом межсетевого экрана.

Если источник не входит в список, это не означает, что приложение автоматически отклонит его соединение. Для определения IP оно не должно доверять переданному им заголовку, но сам запрос всё ещё может попасть в обработчик. Такой сценарий прямо присутствует в тестах проекта.10

Поэтому в документации uo-request-generator отдельно закреплено требование: публичный клиент не должен обращаться к backend в обход прокси. Это условие развёртывания, а не свойство, которое создаёт переменная окружения.1

Практически нужно проверить две независимые вещи:

  • приложение доверяет только предусмотренным прокси
  • сетевой доступ к приложению соответствует этой схеме

Один правильно выбранный IP ещё не доказывает, что запрос прошёл через все необходимые проверки на прокси.

Проверять нужно невозможность сменить счётчик

Для проверки ограничения недостаточно отправить несколько одинаковых запросов и получить 429. Такой тест показывает работу счётчика, но не устойчивость выбора его ключа.

В репозитории есть более точный регрессионный сценарий. Источник соединения остаётся одним и тем же и не входит в список доверенных прокси. Между запросами меняется только X-Forwarded-For. Первые три попытки допускаются, четвёртая получает 429. Подмена заголовка не должна создать новую квоту.10

Есть и положительный сценарий: сведения от разрешённого прокси учитываются штатным механизмом Fastify. Без него можно было бы получить формально защищённую систему, которая просто игнорирует все заголовки и объединяет клиентов в один адрес.10

Проверки выполняются с тестовой заменой LLM. Отдельный тест ограничения подтверждает, что отклонённая попытка не вызывает gateway. Настоящие платные запросы для этого не нужны.10

Но такие тесты моделируют адрес соединения и заголовки внутри приложения. Они не доказывают, что настоящий Nginx перезаписывает заголовок или что порт backend недоступен извне. Эти условия нужно проверять отдельно в развёрнутой схеме.

Корректный IP всё равно не равен пользователю

Даже после правильной настройки несколько пользователей могут иметь один публичный адрес, например за NAT. Ограничение по такому адресу затрагивает их совместно. Проблемы блокировок при совместном использовании IP описаны и в RFC 6269.11

Поэтому в проекте IP — только один уровень защиты. Дополнительно предусмотрены ограничения браузерного клиента, CAPTCHA и общий предохранитель перед вызовами LLM. Их наличие не делает ошибку определения адреса безвредной, но не позволяет возложить всю защиту на один сетевой признак.1

Главный вывод из этого изменения — не в том, что булеву настройку всегда нужно заменять массивом.

Ограничение по IP начинается с происхождения самого IP. Нужно знать, кто сформировал адрес, через какую цепочку он передан и какие участники могут на него повлиять. Только после этого имеет смысл обсуждать размер окна и число разрешённых запросов.


  1. README uo-request-generator на рассматриваемой ревизии, разделы о частоте генераций и общем предохранителе. Описан контракт приложения, а не результаты проверки реальной сетевой конфигурации. ↩︎ ↩︎ ↩︎

  2. PR #60: первая реализация ограничений и PR #69: список доверенных прокси. Оба изменения были приняты. ↩︎

  3. Fastify: Request, свойства ip и ips. ↩︎

  4. Fastify: trustProxy. ↩︎ ↩︎

  5. Проверка конфигурации ограничений. ↩︎ ↩︎

  6. Создание приложения Fastify. ↩︎

  7. Nginx: ngx_http_proxy_module, директива proxy_set_header и переменная $proxy_add_x_forwarded_for. ↩︎ ↩︎

  8. Nginx: ngx_http_realip_module. ↩︎

  9. @fastify/proxy-addr: определение ближайшего недоверенного адреса. ↩︎

  10. Маршрутные тесты ограничения генераций. Адреса соединений, заголовки и LLM моделируются тестами. ↩︎ ↩︎ ↩︎ ↩︎

  11. RFC 6269: Issues with IP Address Sharing. ↩︎