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 не несет ответственности за точность, полноту и достоверность опубликованных данных. Вся информация предоставлена «как есть» и может не соответствовать официальной позиции компании.

CyberCamp 2025 открыл регистрацию.

С 20 по 25 октября пройдет IV онлайн-конференция по кибербезопасности CyberCamp 2025 — крупнейшие киберучения в России, где прокачивают реальные навыки.

Регистрируйся прямо сейчас.

Реклама. 18+ АО «Инфосистемы Джет», ИНН 7729058675