HTTP CRLF в query string и path через нестандартные кодировки: как это работает и как защищаться

HTTP CRLF в query string и path через нестандартные кодировки: как это работает и как защищаться

CRLF — это всего лишь два символа перевода строки, но в HTTP они играют ключевую роль: отделяют заголовки от тела и разделяют строки заголовков. Когда эти символы попадают в неожиданные места — например, в путь URL или параметры запроса — и затем «протаскиваются» в HTTP-заголовки, случается классика жанра: подмены ответов, отравление кэша, мусор в логах и прочие неприятности. В этой статье разберём, почему такие ошибки возникают, как их провоцируют «нестандартные» кодировки и экзотические декодеры, какие последствия чаще всего встречаются и что делать разработчикам и безопасникам, чтобы жить спокойно.

Почему CRLF вообще важно

В HTTP/1.1 разделителем строк заголовков является последовательность CRLF (Carriage Return + Line Feed). Это определено в спецификациях HTTP и означает, что наличие этих символов в данных, из которых формируются заголовки, меняет структуру ответа целиком: после CRLF начинается новая строка заголовка, а после пустой строки — уже тело ответа. То есть одно «невинное» попадание управляющих символов может превратить фрагмент пользовательского ввода в отдельный заголовок или даже «откусить» часть ответа. Подробнее о форматах сообщений и разделителях строк — в RFC 9112 (HTTP/1.1) и RFC 9110 (HTTP Semantics).

Где CRLF появляется «сам по себе»

  • Сервер или промежуточный компонент конкатенирует пользовательский ввод с заголовком (например, строит Location из параметра next).
  • Веб-сервер или обратный прокси пробрасывает данные из URL в заголовок (например, в кастомный X-Forwarded-* или в логи).
  • Фреймворк позволяет разработчику вручную формировать заголовки в контроллере и туда попадает непросанированный ввод.

Где берётся инъекция: путь и query

На практике чаще всего встречаются сценарии, где приложение принимает путь/параметры и пересылает пользователя на новый адрес (редирект), генерирует файл с указанным именем (заголовок Content-Disposition), проксирует часть запроса в другой сервис или пишет информацию в логи/метрики. Если между «пользовательским вводом» и «HTTP-заголовком» нет жёсткой границы, CRLF-инъекция становится реальной.

Пример: небезопасный редирект

Проблемный подход. Контроллер получает GET /redirect?next=/cabinet, а затем делает: res.set('Location', next). Если в next окажутся управляющие символы, можно «дописать» новые заголовки или изменить тело ответа.

Безопасный подход. Использовать встроенную функцию фреймворка для редиректа, которая: (а) жёстко кодирует URL, (б) не позволяет вставлять управляющие символы, (в) применяет канонизацию. Если по требованиям нужно поддерживать относительные URL — принимать только белый список допустимых путей.

Нестандартные кодировки и «магические» декодеры

Когда говорят «CRLF в параметрах», многие представляют собой прямое вхождение символов перевода строки. Но настоящий хаос начинается из-за слоёв декодирования и исторических особенностей кодировок. Одни компоненты делают percent-decode, другие — повторный decode «про запас», третьи поддерживают старые формы записи символов (например, %uXXXX в наследии некоторых платформ), четвёртые пытаются «понять» невалидные последовательности и восстанавливают из них управляющие символы.

Классические правила построения URL описаны в RFC 3986 (URI) и RFC 3987 (IRI), однако многие стековые слои трактуют «грязные» последовательности по-разному, что и открывает путь к инъекциям в граничных случаях.

Что может пойти не так

  • Двойное декодирование. Прокси делает percent-decode, затем приложение делает его ещё раз. На «входе» безобидные символы, на «выходе» — управляющие.
  • Смешанные формы кодирования. Встречаются наследуемые записи символов вроде %u000d/%u000a или иные non-standard формы, которые отдельные декодеры по привычке принимают.
  • Обработка невалидных UTF-8. Некоторые библиотеки стараются «починить» поток байтов и получить символ перевода строки там, где его не ожидали.
  • Нормализация Юникода. Редко, но встречается: нормализация и приведение к NFC/NFD меняет байтовые последовательности, после чего другой слой декодирования интерпретирует поток иначе.
  • Толерантные парсеры. Исторически часть серверов принимала «одиночный LF» в заголовках. Современные спецификации гораздо жёстче, но реальный парк софта неоднороден, особенно на границах (legacy-сервисы, старые прокси).

Хорошая обзорная точка по классам атак на заголовки — в материалах OWASP по CRLF/HTTP response splitting: OWASP: CRLF Injection и OWASP: HTTP Response Splitting.

Типовые последствия

CRLF-инъекция — это не одна уязвимость, а целый «узел» эффектов, зависящих от контекста.

  • Подмена/инъекция заголовков. От Set-Cookie и Location до Content-Type, что позволяет менять поведение браузера и кэшей.
  • HTTP Response Splitting. Разбиение ответа на два: первый заканчивается «раньше», второй начинается с произвольных заголовков и тела (часто используется для cache poisoning). Подробно явление описано в классических работах по безопасности веб-приложений, см. обзор у OWASP выше.
  • Отравление кэша. Подмена заголовков кэширования или статуса ответа, чтобы «застолбить» вредное содержимое в CDN/прокси и отдать его другим пользователям. Практические аспекты описаны, например, в материалах PortSwigger о cache-poisoning (см. их документацию по этой теме: PortSwigger: Web cache poisoning).
  • Инъекции в логи/аналитику. Управляющие символы «ломают» формат логов, скрывают реальные запросы или подсовывают ложные строки, что осложняет расследования.

Антипаттерны в коде

Ниже — два собирательных примера. Они не привязаны к конкретному фреймворку и показывают общую мысль: нельзя напрямую складывать пользовательский ввод и заголовки.

Небезопасно: формирование Location из параметра

// Псевдо-JS
 const next = req.query.next;           // <-- пользовательский ввод
 res.setHeader('Location', next);       // <-- прямое попадание в заголовок
 res.statusCode = 302;
 res.end();
 

Безопаснее: проверка, канонизация, encode

// Псевдо-JS
 const next = String(req.query.next || '/');
 if (!next.startsWith('/')) { return res.redirect(302, '/'); } // только относительные пути
 if (/[r
]/.test(next)) { return res.redirect(302, '/'); } // жёсткий запрет управляющих res.redirect(302, next); // используем API фреймворка, который сам ставит заголовки корректно

Небезопасно: имя файла в Content-Disposition

// Псевдо-Python
 filename = request.args.get('name', 'report.txt')
 response.headers['Content-Disposition'] = f'attachment; filename="{filename}"'  # опасно
 

Безопаснее: RFC-совместимое кодирование параметров

// Псевдо-Python
 import re, urllib.parse
 filename = request.args.get('name', 'report.txt')
 if re.search(r'[r
]', filename): filename = 'report.txt' # Используем filename* с RFC 5987 / 6266 совместимым кодированием disposition = "attachment; filename*=UTF-8''" + urllib.parse.quote(filename, safe='') response.headers['Content-Disposition'] = disposition

Рекомендации по корректной передаче имён файлов в заголовках см. в RFC 6266.

Как тестировать (безопасно и легально)

Тестируйте только на своих стендах или с явного разрешения владельца системы. Цель — подтвердить корректную обработку «неприятных» символов и отсутствия двойного декодирования.

  • Набор символов. Прогоните входы, содержащие управляющие символы в разных представлениях (буквально, в percent-encoding, в «экзотических» формах), но делайте это исключительно на тестовой среде.
  • Слоистость. Прокси → веб-сервер → приложение: проверяйте, не происходит ли декодирование на каждом шаге.
  • Инструменты. Удобны ручные проверочные запросы (curl), а также декодер/повторитель из Burp Suite. Для систематического подхода пригодится OWASP Web Security Testing Guide.

Практики защиты

Главная идея — «жёсткая граница» между пользовательским вводом и HTTP-заголовками. Всё, что попадает в заголовок, должно быть либо из белого списка, либо корректно закодировано под конкретный контекст.

  • Не конкатенируйте строки заголовков вручную. Используйте API фреймворка (redirect(), set_cookie(), send_file() и т.п.), которые сами валидируют значения.
  • Запрещайте управляющие символы. Простой фильтр [r
    ]
    на всех путях, попадающих в заголовки (имена файлов, пути редиректа, названия метрик).
  • Канонизация → валидация → кодирование. Сначала приводим строку к каноническому виду (одна форма кодирования), затем валидируем (белые списки), затем кодируем под целевой контекст (например, filename*).
  • Один decode на границе. Избавьтесь от «повторной распаковки» входа в разных слоях. Явно фиксируйте, где происходит декодирование URL.
  • Прокси и веб-серверы. Включайте строгую обработку заголовков, отбрасывайте невалидные, ограничивайте длины. Следуйте рекомендациям в спецификациях HTTP по формату сообщений ( RFC 9112).
  • Логи. Экранируйте контрольные символы перед записью, либо логируйте в структурированный формат (JSON Lines) с явным escaping.
  • Кэш/CDN. Включите «строгий» режим для кэшей, задавайте Vary/Cache-Control на стороне приложения, чтобы снизить риск неожиданных комбинаций заголовков.

Чек-лист аудита

  1. Ищем места, где пользовательский ввод попадает в любые заголовки (редиректы, вложения, прокси-пробросы, метрики).
  2. Проверяем, что строка проходит проверку на отсутствие r/
    и не может попасть в заголовок «как есть».
  3. Убеждаемся, что используется API фреймворка, а не ручная сборка строк.
  4. Фиксируем точку единственного декодирования URL и запрещаем «скрытое» повторное декодирование в промежуточных слоях.
  5. Тестируем на стенде типовые крайние кейсы: длинные значения, необычные кодировки, «грязные» последовательности.
  6. Проверяем конфигурацию веб-сервера/прокси: отбрасывание невалидных заголовков, ограничения на размер/количество, строгая парсинг-модель.

FAQ и нюансы

Достаточно ли фильтровать только «
»?

Нет. Фильтровать нужно оба управляющих символа: и r, и
. В реальных стеках встречаются парсеры, толерантные к «одиночному LF», поэтому надёжнее запрещать любые управляющие символы (включая другие коды управления) в данных, которые попадут в заголовок.

А если мне действительно нужно передать «особые» символы?

Кодируйте в соответствии с контекстом. Для URL — стандартное percent-encoding, для имён файлов — параметр filename* с кодировкой UTF-8 (см. RFC 6266), для тел запроса/ответа — используйте корректный Content-Type и экранирование внутри формата.

Может ли проблема проявляться только «на проде», а на dev/stage — нет?

Да. Часто причина — различия в цепочке: локально вы ходите напрямую к приложению, а в проде стоит цепочка из CDN → WAF → обратного прокси → балансировщика → приложения. Любой из этих слоёв может добавлять/убирать декодирование и менять трактовку входа.

Инструменты и полезные ссылки

Итоги

CRLF-инъекции через query и path — это всегда про границы и контексты. Как только пользовательский ввод просачивается в заголовки без строгих правил, вы играете в лотерею со стеком: кто-то декодирует «лишний раз», кто-то сочтёт странную последовательность валидной — и вот уже заголовки «поехали». Решение — дисциплина: канонизация, валидация, контекстное кодирование и отказ от ручной сборки заголовков. Тогда любые «нестандартные» кодировки останутся просто байтами, а не дверью в ваш ответ и кэш.

crlf double decoding header injection HTTP OWASP path query string response splitting url encoding web cache poisoning
Alt text
Обращаем внимание, что все материалы в этом блоге представляют личное мнение их авторов. Редакция SecurityLab.ru не несет ответственности за точность, полноту и достоверность опубликованных данных. Вся информация предоставлена «как есть» и может не соответствовать официальной позиции компании.
310K
долларов
до 18 лет
Антипов жжет
Ребёнок как убыточный
актив. Считаем честно.
Почему рожают меньше те, кто умеет считать на десять лет вперёд.

FREE
100%
Кибербезопасность · Обучение
УЧИСЬ!
ИЛИ
ВЗЛОМАЮТ
Лучшие ИБ-мероприятия
и вебинары — в одном месте
ПОДПИШИСЬ
T.ME/SECWEBINARS