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
на стороне приложения, чтобы снизить риск неожиданных комбинаций заголовков.
Чек-лист аудита
- Ищем места, где пользовательский ввод попадает в любые заголовки (редиректы, вложения, прокси-пробросы, метрики).
- Проверяем, что строка проходит проверку на отсутствие
r
/
и не может попасть в заголовок «как есть». - Убеждаемся, что используется API фреймворка, а не ручная сборка строк.
- Фиксируем точку единственного декодирования URL и запрещаем «скрытое» повторное декодирование в промежуточных слоях.
- Тестируем на стенде типовые крайние кейсы: длинные значения, необычные кодировки, «грязные» последовательности.
- Проверяем конфигурацию веб-сервера/прокси: отбрасывание невалидных заголовков, ограничения на размер/количество, строгая парсинг-модель.
FAQ и нюансы
Достаточно ли фильтровать только «
»?
Нет. Фильтровать нужно оба управляющих символа: и r
, и
. В реальных стеках встречаются парсеры, толерантные к «одиночному LF», поэтому надёжнее запрещать любые управляющие символы (включая другие коды управления) в данных, которые попадут в заголовок.
А если мне действительно нужно передать «особые» символы?
Кодируйте в соответствии с контекстом. Для URL — стандартное percent-encoding, для имён файлов — параметр filename*
с кодировкой UTF-8 (см. RFC 6266 ), для тел запроса/ответа — используйте корректный Content-Type
и экранирование внутри формата.
Может ли проблема проявляться только «на проде», а на dev/stage — нет?
Да. Часто причина — различия в цепочке: локально вы ходите напрямую к приложению, а в проде стоит цепочка из CDN → WAF → обратного прокси → балансировщика → приложения. Любой из этих слоёв может добавлять/убирать декодирование и менять трактовку входа.
Инструменты и полезные ссылки
- RFC 9112: HTTP/1.1 — спецификация формата сообщений и разделителей строк.
- RFC 9110: HTTP Semantics — семантика HTTP и заголовков.
- RFC 3986: URI и RFC 3987: IRI — как кодируются URL/IRI.
- OWASP: CRLF Injection и OWASP: HTTP Response Splitting — обзор проблематики и рисков.
- PortSwigger: Web cache poisoning — практические аспекты отравления кэша.
Итоги
CRLF-инъекции через query и path — это всегда про границы и контексты. Как только пользовательский ввод просачивается в заголовки без строгих правил, вы играете в лотерею со стеком: кто-то декодирует «лишний раз», кто-то сочтёт странную последовательность валидной — и вот уже заголовки «поехали». Решение — дисциплина: канонизация, валидация, контекстное кодирование и отказ от ручной сборки заголовков. Тогда любые «нестандартные» кодировки останутся просто байтами, а не дверью в ваш ответ и кэш.