Руководства по безопасности
Что такое подделка X-Forwarded-For (XFF) — ловушка конфигурации доверенного прокси
X-Forwarded-For (XFF) — заголовок, который клиент может подделать: сканеры прячут в нём пробы SQLi/XSS, а настройка «доверять всем прокси» пропускает поддельное значение в ваше приложение. Защитный разбор: почему устояла эшелонированная защита и настоящее исправление доверенного прокси.
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, командой — это узкое место, через которое проходит всё, и это последняя линия обороны. Делайте вход и выход как две отдельные вещи.
Самый большой урок: быть громко отвергнутым безопаснее
Здесь атака провалилась и была сделана видимой как поток уведомлений об ошибках. Иронично, но это не плохо: быть громко отвергнутым лучше, чем быть тихо пропущенным. Не отметайте уведомления об ошибках как «шум» — используйте их как датчик аномалий. Не останавливайтесь на «мы заблокировали воздействие»; исправьте «почему оно достигло внутренностей». Такова позиция этого сайта.
Читать дальше
- Глоссарий: что такое SQL-инъекция / что такое XSS / что такое SSRF
- Основы: основы безопасности: .env и API-ключи
FAQ
QМожно ли доверять X-Forwarded-For?
Не как есть. XFF — заголовок, который клиент может задать свободно — атакующий может подделать реалистичный IP или строку-пробу инъекции. Единственный заслуживающий доверия XFF — тот, что добавлен прокси, который ВЫ поставили впереди и которому явно доверяете.
QЧто, если я не использую обратный прокси или CDN?
Не доверяйте XFF вовсе (оставьте значение по умолчанию фреймворка). Если впереди нет прокси, но вы «доверяете всем прокси», любой может скормить вашему приложению поддельный IP. Нет прокси на пути → не доверять ничему — правильная настройка.
QДостаточно ли санировать IP-заголовок?
Это останавливает пробы инъекций и поток ошибок, но это гигиена (остановка кровотечения). «Выглядящий корректным IP» — не «реальный IP». Доверие к реальному IP клиента должно приходить из конфигурации доверенного прокси, а не из санирования.
QЯ исправил IP-заголовок, но та же ошибка вернулась. Почему?
Та же полезная нагрузка была перемещена в другую точку входа (например, заголовок User-Agent) и достигла того же сохраняемого столбца. Патчить заголовки по одному — это «убей крота». Рассуждайте в обратную сторону от «в какой столбец БД / вывод недоверенный ввод в итоге попадает?» и санируйте все питающие его входы (IP-заголовки + User-Agent и т. д.) вместе.