В логе адрес, с которого запрос не приходил. Его вписал сам клиент
Недавно разбирал блокировку у заказчика. Сработало правило «десять неудачных входов с одного адреса - бан». Забанило адрес, с которого никто ничего не делал. Через два часа забанило ещё один, потом ещё - и все из документационного диапазона, который в интернете не маршрутизируется.
Логика правила была в порядке. Просто в качестве адреса клиента оно брало то, что клиент про себя написал сам.
Заголовок пишет кто угодно
X-Forwarded-For - обычный HTTP-заголовок. Никакой защиты, подписи или привязки к соединению у него нет; это строка в запросе, и отправить её может любой:
$ curl -s -H 'X-Forwarded-For: 198.51.100.7' http://127.0.0.1:8931/
# на стороне сервера:
XFF: 198.51.100.7
Можно сразу списком, изобразив цепочку прокси, которой не было:
$ curl -s -H 'X-Forwarded-For: 203.0.113.1, 198.51.100.7' http://127.0.0.1:8931/
# на стороне сервера:
XFF: 203.0.113.1, 198.51.100.7
Это не уязвимость сервера и не ошибка curl. Так заголовок и устроен: он существует ровно потому, что за прокси настоящий адрес источника теряется, и кто-то должен его туда положить. Стандарт не описывает способ отличить «положил мой прокси» от «положил клиент», потому что на уровне HTTP такого способа нет.
Что делает прокси
Прокси не заменяет заголовок, а дописывает значение в конец - причём дописывает не свой адрес, а тот, с которого получил соединение (в nginx это $proxy_add_x_forwarded_for). Отсюда и ломается почти всё остальное.
Если клиент прислал X-Forwarded-For: 203.0.113.1, а его запрос прошёл через ваш балансировщик, приложение получит:
X-Forwarded-For: 203.0.113.1, <адрес, с которого балансировщик получил соединение>
Из этого следует, что верить можно только правому концу списка, и то не всему. Каждый ваш прокси в цепочке добавил ровно один элемент справа - и вот эти элементы вы контролируете. Всё, что левее, пришло снаружи и написано неизвестно кем.
Адрес клиента - это N-й элемент с конца, где N - количество ваших собственных прокси на пути. Не первый слева, не последний справа. Условие тут одно: каждый ваш прокси действительно дописывает заголовок - сам nginx без proxy_set_header X-Forwarded-For его не изменяет, и тогда N считается только по тем узлам, которые дописывают.
Ошибка «берём самый левый» встречается чаще прочих, и она полностью управляется клиентом. С «берём самый правый» тоньше: при одном дописывающем прокси там как раз адрес клиента, и до поры всё работает - а при двух и более туда попадает адрес предыдущего прокси, и адреса всех клиентов сливаются в один.
Как это выглядит в конфигурации
В nginx за это отвечает модуль realip. Работают тут две директивы, и про вторую забывают чаще: set_real_ip_from задаёт доверие, real_ip_recursive - как разбирать список.
set_real_ip_from 198.51.100.0/24; # адреса ваших балансировщиков
set_real_ip_from 203.0.113.10;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
set_real_ip_from - список тех, кому позволено сообщать адрес клиента. Соединение пришло не с этих адресов - заголовок игнорируется целиком, и $remote_addr остаётся адресом сокета. Именно этот список и делает всю работу; без него real_ip_header означает «верить всем».
real_ip_recursive on предписывает nginx идти по списку справа налево, пропуская доверенные адреса, пока не встретится первый недоверенный, - его и считают адресом клиента. С off берётся ровно один элемент, самый правый; в цепочке из двух ваших прокси там лежит адрес предыдущего прокси, а не клиента.
Рекурсивный разбор безопасен только тогда, когда список set_real_ip_from описывает реальную цепочку. Если в него по привычке внесли всю приватную адресацию 10.0.0.0/8, а приложение доступно ещё и изнутри сети, то любой, кто может обратиться к нему из этой сети, снова управляет результатом.
Отдельно про облачные балансировщики
У управляемого балансировщика обычно нет постоянного адреса - он масштабируется, и адреса меняются. Прописать set_real_ip_from конкретным списком не выйдет, а прописать всю подсеть - значит доверять всему, что в этой подсети запущено, включая соседние сервисы.
Рабочий вариант - не определять доверие по адресу источника, а заставить внешний край отбрасывать то, что прислал клиент. Тогда приложение получит список, целиком составленный вашей инфраструктурой. Такой режим - или его эквивалент в виде отдельного заголовка, который край всегда затирает, - есть у части управляемых балансировщиков; называется он везде по-разному и по умолчанию не включён, потому что по умолчанию цепочка должна сохраняться. Читать документацию своего провайдера тут придётся: у одних это переключатель обработки X-Forwarded-For с режимами «дописать», «сохранить», «удалить», у других задача решена вообще другим заголовком.
Есть и обходной путь: свой заголовок с непредсказуемым именем, который внешний край всегда перезаписывает. Работает, но это соглашение, а не механизм: оно держится ровно до того момента, когда кто-то поставит перед приложением ещё один прокси и не узнает о договорённости.
Что ломается, если адрес взят неверно
Ограничение частоты запросов. Счётчик по подставному адресу не ограничивает никого: значение меняется от запроса к запросу, и каждый запрос выглядит первым. Работает это и в обратную сторону - подставив в заголовок адрес другого пользователя, можно исчерпать чужую квоту.
Списки разрешённых адресов. Проверка «адрес из офисной сети - пускаем в админку» на неправильно взятом заголовке равносильна отсутствию проверки.
Расследование. Адрес в логе неотличим от проверенного значения и переезжает в отчёт без оговорки. Через неделю выясняется, что доверять ему было нельзя, - и переделывать надо весь разбор, а не одну строчку.
Геопривязка и антифрод. Признак «вход из другой страны» считается по тому же полю. Значит, страну входа выбирает тот же, кто шлёт запрос.
Как логировать, чтобы потом не переделывать
Писать оба значения, всегда и рядом. Адрес сокета - это факт, его подделать нельзя без контроля над маршрутизацией. Заголовок - это утверждение. В логе они должны стоять как два разных поля, и по названиям должно быть видно, где что:
log_format proxied '$remote_addr $realip_remote_addr "$http_x_forwarded_for" '
'$status $request_time "$request"';
После включения realip переменная $remote_addr содержит уже вычисленный адрес клиента, а исходный адрес сокета переезжает в $realip_remote_addr. Сырой заголовок пишется отдельно - он нужен, когда придётся разбираться, почему вычисление дало не то.
Расхождение этих полей - самостоятельный сигнал. В нормальной работе длина цепочки постоянна и равна числу ваших прокси. Запросы, где элементов больше или где слева стоят адреса из приватных и документационных диапазонов, - это либо чужая автоматика, либо ваш собственный клиент, который зачем-то ставит заголовок. И то и другое полезно знать до инцидента, а не во время.
Что посмотреть у себя
Открыть конфигурацию входного узла и найти real_ip_header. Если он есть, а set_real_ip_from нет или в нём широкая приватная подсеть - это и есть находка.
Посчитать по логам распределение длины цепочки в X-Forwarded-For. Ожидаемое значение - одно и то же число. Разброс означает, что кто-то присылает заголовок сам.
Найти в коде приложения все места, где берётся адрес клиента. Обычно их несколько, и написаны они в разное время: одно берёт remote_addr, другое разбирает заголовок само и берёт первый элемент, третье использует готовый метод фреймворка со своими правилами доверия. Ограничение частоты запросов и проверка списков должны работать от одного и того же значения - иначе одно из них можно обойти, не трогая другое.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.