X-Forwarded-For (XFF) — это заголовок, сообщающий вашему приложению «реальный IP посетителя — вот этот», когда трафик проходит через прокси. Загвоздка: клиент может подделать это значение свободно. Вот распространённый случай инди-разработчика — «нас задело сканирование с подделкой XFF» — превращённый в урок, обезличенный, без воспроизводимых шагов атаки.
- Тип
- Подделка X-Forwarded-For + зондирование инъекциями (автоматизированное сканирование)
- Воздействие
- Нет (остановлено эшелонированной защитой)
- Как всплыло
- Запросы атаки давали 500; поток писем об исключениях
- Сработавшие меры защиты
- ① плейсхолдеры (привязанные значения) ② валидация кодировки БД
- Оставшаяся ошибка конфигурации
- «доверять всем прокси» — при отсутствии прокси впереди
- Реакция
- Санировать IP-заголовки на границе (краткосрочно) → исправить конфигурацию доверенного прокси (первопричина)
Сначала разделите: «атака» или «моя собственная ошибка»?
Не прыгайте к «атака!» при виде ошибок. Если вы только что меняли конфигурацию, взвесьте «может, моё изменение это сделало» с равной серьёзностью.
Заподозрите своё собственное изменение
Пусть значение будет уликой
Целевая атака или сканирование?
Взгляд этого сайта: восстанавливая причину по симптомам, проверяйте каждую гипотезу данными
«То, что я только что трогал, выглядит подозрительно» — хорошая отправная точка. Но если улика (здесь — само значение) её опровергает, отпустите её. Сформируйте несколько гипотез и исключайте их одну за другой с помощью данных, а не догадок; так вы будете гораздо реже ошибаться в причине.
Чем была атака: подделанный XFF с пробами инъекций
Бот помещает строку-пробу в XFF «вместо IP», оставляя в конце лишь нормально выглядящий IP. Он проверяет одно:
«Доверяет ли это приложение значению XFF и подмешивает ли его сырым в запрос к БД или печатает прямо в HTML?»
Если приложение небрежно, через XFF срабатывает SQL-инъекция или XSS. Атака злоупотребляет «заголовком, несущим IP», как местом для отправки произвольных строк.
Почему ущерба не было: две меры защиты
Мера защиты ① привязанные значения (плейсхолдеры)
Значение IP передавалось через плейсхолдер как данные, а не конкатенацией строк. Ввод не может быть истолкован как команда, поэтому SQLi не может сформироваться по дизайну. Это одно из главных преимуществ использования фреймворка.
Мера защиты ② валидация кодировки БД
Некорректные байты (переусложнённый UTF-8 и т. п.) — это недопустимые последовательности, которые кодировка столбца не может хранить. БД отвергла запись и выбросила исключение — атака провалилась и была обнаружена.
Всё, что произошло: собственные запросы атакующего давали 500, и вышел поток писем об исключениях. Ни утечки, ни подмены, ни несанкционированного входа. Учебниковый случай эшелонированной защиты, работающей как задумано.
Настоящая проблема: доверие всем прокси
Если бы мы остановились здесь, проблемы бы не было, но одну вещь нельзя игнорировать. Почему подделанный XFF вообще достиг обработки IP?
✗ Доверять всем прокси (wildcard)
Поддельный XFF клиента → принят как «реальный IP» → логи, сессии и IP-проверки получают поддельное значение
✓ Не доверять ничему (по умолчанию)
Поддельный XFF проигнорирован → используется реальный IP сокета → подделка не имеет эффекта
У этой конфигурации не было ни обратного прокси, ни CDN — веб-сервер был выставлен напрямую. Так что XFF никогда не нуждался в доверии, а его всё же поставили на «доверять всем». «Просто разрешить всё» — распространённая настройка этапа разработки, которая остаётся в продакшене.
Легко упустить: подделка XFF — не только про инъекции
Всё, что доверяет IP клиента, в итоге использует поддельное значение: обход ограничения частоты, обход белого списка IP, уклонение от бана, подделанные журналы аудита. Даже при надёжной защите от инъекций это отдельная проблема. Инвентаризируйте «где я доверяю IP?» и проверьте основание этого доверия (= вашу конфигурацию прокси).
Исправление: санировать на границе → задать доверие правильно
Санировать IP-заголовки на границе (краткосрочно, сразу)
Задать доверенные прокси правильно (первопричина)
http/https, поэтому изменения могут вызвать циклы редиректа — сначала проверьте на одной среде.Не путайте «валидный IP» с «реальным IP»
Проверьте: после исправления повторите атаку, чтобы подтвердить
Превратите «думаю, я исправил» в «оно точно остановилось». ① примените только к одной среде → ② сами отправьте тот же некорректный заголовок → ③ убедитесь, что счётчики ошибок не растут → ④ убедитесь, что нормальный доступ всё ещё работает → ⑤ выкатите везде. Поэтапная выкатка предотвращает аварии.
Продолжение: после блокировки IP-заголовка атака перешла на User-Agent
Через десятки минут после добавления санирования вернулась та же ошибка — на этот раз в другом столбце: user_agent, а не ip_address. Заблокированный на 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, командой) — единственное место, через которое проходит каждое значение, поэтому именно там и должна быть финальная проверка. Делайте вход и выход как две отдельные вещи.
Урок: безопаснее, когда плохой ввод отвергается с видимой ошибкой
Здесь атака провалилась и стала видна как поток уведомлений об ошибках. Это не плохой исход: отказ с видимой ошибкой безопаснее, чем незаметный пропуск. Не отметайте уведомления об ошибках как шум; используйте их, чтобы замечать аномалии. Не останавливайтесь на «мы заблокировали воздействие»; исправьте «почему оно достигло внутренностей». Такова позиция этого сайта.
Читать дальше
- Пример: CVE-2026-102895 GPUStack (аутентификация, доверявшая источнику запроса, позволяла неаутентифицированным лицам действовать как администратор на ИИ-платформе)
- Глоссарий: что такое SQL-инъекция / что такое XSS / что такое SSRF
- Основы: основы безопасности: .env и API-ключи
- Где это важно: ограничение частоты запросов и защита от злоупотреблений (по какому IP считать запросы, зависит от обработки XFF)
- Общая картина: много ли уязвимостей в Next.js и React? (что находили во встроенных прокси-функциях фреймворков)