Руководства по безопасности

Что такое подделка X-Forwarded-For (XFF): ошибка конфигурации доверенного прокси и защита

Заголовок X-Forwarded-For клиент может подделать, а настройка «доверять всем прокси» пропускает подделку в приложение. Как исправить настройку прокси.

Опубликовано 2026-06-08 10 мин чтения
На этой странице

X-Forwarded-For (XFF) — это заголовок, сообщающий вашему приложению «реальный IP посетителя — вот этот», когда трафик проходит через прокси. Загвоздка: клиент может подделать это значение свободно. Вот распространённый случай инди-разработчика — «нас задело сканирование с подделкой XFF» — превращённый в урок, обезличенный, без воспроизводимых шагов атаки.

Дело
Тип
Подделка X-Forwarded-For + зондирование инъекциями (автоматизированное сканирование)
Воздействие
Нет (остановлено эшелонированной защитой)
Как всплыло
Запросы атаки давали 500; поток писем об исключениях
Сработавшие меры защиты
① плейсхолдеры (привязанные значения) ② валидация кодировки БД
Оставшаяся ошибка конфигурации
«доверять всем прокси» — при отсутствии прокси впереди
Реакция
Санировать IP-заголовки на границе (краткосрочно) → исправить конфигурацию доверенного прокси (первопричина)
0
Реальное воздействие (утечка/подмена)
2
Сработавшие меры защиты
все
Доверенные прокси (wildcard)
граница
Основное исправление

Сначала разделите: «атака» или «моя собственная ошибка»?

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

1

Заподозрите своё собственное изменение

Гипотеза: недавнее изменение конфигурации заставило пользователей входить повторно. Но это не объясняет некорректные последовательности байтов в сохранённом значении → отвергнуто.
2

Пусть значение будет уликой

Появились многократно URL-кодированные кавычки и переусложнённые (избыточные) последовательности байтов — не значения, которые производят реальные пользователи или прокси → подтверждённая вредоносная полезная нагрузка.
3

Целевая атака или сканирование?

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

Взгляд этого сайта: восстанавливая причину по симптомам, проверяйте каждую гипотезу данными

«То, что я только что трогал, выглядит подозрительно» — хорошая отправная точка. Но если улика (здесь — само значение) её опровергает, отпустите её. Сформируйте несколько гипотез и исключайте их одну за другой с помощью данных, а не догадок; так вы будете гораздо реже ошибаться в причине.

Чем была атака: подделанный XFF с пробами инъекций

Бот помещает строку-пробу в XFF «вместо IP», оставляя в конце лишь нормально выглядящий IP. Он проверяет одно:

«Доверяет ли это приложение значению XFF и подмешивает ли его сырым в запрос к БД или печатает прямо в HTML?»

Если приложение небрежно, через XFF срабатывает SQL-инъекция или XSS. Атака злоупотребляет «заголовком, несущим IP», как местом для отправки произвольных строк.

Почему ущерба не было: две меры защиты

Мера защиты ① привязанные значения (плейсхолдеры)

Значение IP передавалось через плейсхолдер как данные, а не конкатенацией строк. Ввод не может быть истолкован как команда, поэтому SQLi не может сформироваться по дизайну. Это одно из главных преимуществ использования фреймворка.

Мера защиты ② валидация кодировки БД

Некорректные байты (переусложнённый UTF-8 и т. п.) — это недопустимые последовательности, которые кодировка столбца не может хранить. БД отвергла запись и выбросила исключение — атака провалилась и была обнаружена.

Всё, что произошло: собственные запросы атакующего давали 500, и вышел поток писем об исключениях. Ни утечки, ни подмены, ни несанкционированного входа. Учебниковый случай эшелонированной защиты, работающей как задумано.

Настоящая проблема: доверие всем прокси

Если бы мы остановились здесь, проблемы бы не было, но одну вещь нельзя игнорировать. Почему подделанный XFF вообще достиг обработки IP?

✗ Доверять всем прокси (wildcard)

Поддельный XFF клиента → принят как «реальный IP» → логи, сессии и IP-проверки получают поддельное значение

✓ Не доверять ничему (по умолчанию)

Поддельный XFF проигнорирован → используется реальный IP сокета → подделка не имеет эффекта

С доверенными прокси, заданными как wildcard, чей угодно подделанный XFF проходит как «реальный IP». При отсутствии прокси впереди «не доверять ничему» — правильно.

У этой конфигурации не было ни обратного прокси, ни CDN — веб-сервер был выставлен напрямую. Так что XFF никогда не нуждался в доверии, а его всё же поставили на «доверять всем». «Просто разрешить всё» — распространённая настройка этапа разработки, которая остаётся в продакшене.

Легко упустить: подделка XFF — не только про инъекции

Всё, что доверяет IP клиента, в итоге использует поддельное значение: обход ограничения частоты, обход белого списка IP, уклонение от бана, подделанные журналы аудита. Даже при надёжной защите от инъекций это отдельная проблема. Инвентаризируйте «где я доверяю IP?» и проверьте основание этого доверия (= вашу конфигурацию прокси).

Исправление: санировать на границе → задать доверие правильно

1

Санировать IP-заголовки на границе (краткосрочно, сразу)

До запуска логики доверенного прокси разбейте XFF, проверьте каждую часть как «это валидный IP?», оставьте только валидные IP; если ни одного — отбросьте заголовок целиком. Реальные IP реальных пользователей проходят, поэтому нет воздействия; строки-пробы выбрасываются.
2

Задать доверенные прокси правильно (первопричина)

Уберите wildcard. Нет прокси впереди → не доверять ничему. Доверие прокси также влияет на определение http/https, поэтому изменения могут вызвать циклы редиректа — сначала проверьте на одной среде.
3

Не путайте «валидный IP» с «реальным IP»

Санирование исправляет только формат. Хорошо сформированный IP всё равно может быть подделанным значением. Доверие к реальному IP приходит только из конфигурации прокси на шаге 2.

Проверьте: после исправления повторите атаку, чтобы подтвердить

Превратите «думаю, я исправил» в «оно точно остановилось». ① примените только к одной среде → ② сами отправьте тот же некорректный заголовок → ③ убедитесь, что счётчики ошибок не растут → ④ убедитесь, что нормальный доступ всё ещё работает → ⑤ выкатите везде. Поэтапная выкатка предотвращает аварии.

Продолжение: после блокировки IP-заголовка атака перешла на User-Agent

Через десятки минут после добавления санирования вернулась та же ошибка — на этот раз в другом столбце: user_agent, а не ip_address. Заблокированный на IP-заголовке, атакующий переместил ту же полезную нагрузку в заголовок User-Agent. Это потребовало другого подхода.

Входы: XFF · User-Agent · Referer · … (бесконечно)
↓ но фактически сохраняются
Сток: только ip_address и user_agent в таблице сессий
↓ значит, входы для санирования —
лишь заголовки, питающие эти два (IP-заголовки + User-Agent)
Если блокировать опасные заголовки по одному, атака просто переходит на другой заголовок. Отталкивайтесь от того, где недоверенный ввод в итоге сохраняется (сток), — и входы, которые нужно обработать, сводятся к короткому фиксированному списку.

Правильный вопрос — не «какой заголовок опасен?», а «в какой столбец БД недоверенный ввод в итоге приземляется?». Если единственные стоки — ip_address и user_agent, санирование входов, питающих их (IP-заголовки + User-Agent, плюс Referer на всякий случай), останавливает ошибки, порождённые сессиями, по дизайну; перенос полезной нагрузки в другие, несохраняемые заголовки ошибки не вызывает. Легитимные User-Agent — ASCII/валидный UTF-8, поэтому удаление лишь невалидных байтов не имеет воздействия.

Взгляд этого сайта: отталкивайтесь от стока, а не блокируйте входы по одному

Блокировать заголовки по одному не работает: атакующий всегда может воспользоваться другим заголовком. Рассуждайте в обратную сторону от стока (сохраняемого столбца / вывода) и перечислите каждый достигающий его вход разом. Как показал этот случай, когда атака перешла с IP-заголовка на User-Agent: исправляя один заголовок, одновременно обрабатывайте и другие входы, достигающие того же стока. И снова — мы повторили вторую атаку и подтвердили, что она остановилась, до выкатки.

А затем: защита прямо перед сохранением значения

Сразу после обработки и User-Agent вернулась та же ошибка столбца ip_address. Входное санирование было развёрнуто, и повтор того же некорректного заголовка в самопроверке не воспроизводил это — а в продакшене ошибка всё равно повторялась. В этом ключевой момент случая.

Причина: логика фреймворка по «разрешению IP клиента» внутренне сложна — множество нотаций заголовков (X-Forwarded-For, Forwarded, кастомные заголовки), сочетания с настройками доверия прокси, порядок разбора и кеширование. Где-то на этих внутренних путях вредоносное значение вернулось как «IP клиента» через маршрут, который мы не санировали. Проверка на входе не может поймать всё.

✗ Защищаться на входе

Санировать заголовки запроса → вредоносный IP клиента возвращается через другой внутренний путь → обойдено

✓ Защищаться на выходе

Проверять прямо перед записью в БД → единственное место, через которое проходит каждое значение → нет обхода

Входной фильтр (проверка входящих значений) можно обойти через внутренние пути разрешения. Выход (прямо перед записью в БД) — единственное место, через которое проходит каждое значение, поэтому мимо него ничто не пройдёт.

Поэтому мы сменили подход и защитились на выходе (прямо перед записью в БД). Создайте подкласс класса хранения сессий и переопределите только части, производящие значение (концептуально): сохраняемый IP становится null, если это не валидный IP; у сохраняемого User-Agent удаляются невалидные байты и ограничивается длина. Как бы разрешение IP клиента ни вело себя внутренне, проверка всегда выполняется прямо перед записью, поэтому мимо неё ничто не пройдёт. После отправки вредоносного заголовка по обоим маршрутам атаки по десятку с лишним раз счётчики ошибок не росли, а легитимные сессии продолжали работать.

Взгляд этого сайта: финальная проверка должна быть на выходе (в точке использования)

Базовое правило безопасности: «не доверяй вводу; всегда валидируй/экранируй в точке использования (на выходе)». Входные проверки помогают отвергать рано, но информация, которую фреймворк разрешает/выводит внутренне (вроде IP клиента), может пройти мимо входной проверки и появиться снова. Валидация/экранирование прямо перед опасным стоком (БД, HTML, командой) — единственное место, через которое проходит каждое значение, поэтому именно там и должна быть финальная проверка. Делайте вход и выход как две отдельные вещи.

Урок: безопаснее, когда плохой ввод отвергается с видимой ошибкой

Здесь атака провалилась и стала видна как поток уведомлений об ошибках. Это не плохой исход: отказ с видимой ошибкой безопаснее, чем незаметный пропуск. Не отметайте уведомления об ошибках как шум; используйте их, чтобы замечать аномалии. Не останавливайтесь на «мы заблокировали воздействие»; исправьте «почему оно достигло внутренностей». Такова позиция этого сайта.

Читать дальше

FAQ

QМожно ли доверять X-Forwarded-For?
A

Не как есть. XFF — заголовок, который клиент может задать свободно — атакующий может подделать реалистичный IP или строку-пробу инъекции. Единственный заслуживающий доверия XFF — тот, что добавлен прокси, который ВЫ поставили впереди и которому явно доверяете.

QЧто, если я не использую обратный прокси или CDN?
A

Не доверяйте XFF вовсе (оставьте значение по умолчанию фреймворка). Если впереди нет прокси, но вы «доверяете всем прокси», любой может передать вашему приложению поддельный IP. Нет прокси на пути → не доверять ничему — правильная настройка.

QДостаточно ли санировать IP-заголовок?
A

Это останавливает пробы инъекций и поток ошибок, но это лишь краткосрочное исправление. «Выглядящий корректным IP» — не «реальный IP». Доверие к реальному IP клиента должно приходить из конфигурации доверенного прокси, а не из санирования.

QЯ исправил IP-заголовок, но та же ошибка вернулась. Почему?
A

Та же полезная нагрузка была перемещена в другой заголовок (например, User-Agent) и достигла того же сохраняемого столбца. Если патчить заголовки по одному, атакующий просто переходит к другому. Рассуждайте в обратную сторону от «в какой столбец БД / вывод недоверенный ввод в итоге попадает?» и санируйте все питающие его входы (IP-заголовки + User-Agent и т. д.) вместе.