RE:NODE

Как заблокировать HTTP/1.0 в nginx и Apache и ничего не сломать

Автор Команда RE:NODE
16 мин чтения


Чтобы заблокировать HTTP/1.0 в nginx, проверьте $server_protocol и завершите запрос сразу: if ($server_protocol = HTTP/1.0) { return 444; } внутри блока server. В Apache сопоставьте %{SERVER_PROTOCOL} через mod_rewrite и ответьте 403 либо используйте блок <If> с Require all denied. И то и другое пишется за минуту. Дольше приходится решать, нужно ли это вам вообще: трафик, на который вы целитесь (старые боты, массовые сканеры, запросы без заголовка Host), перемешан с несколькими законными клиентами, которые до сих пор говорят на HTTP/1.0, - и очень часто среди них оказывается ваш собственный reverse proxy. Сначала логируйте, потом блокируйте, а в большинстве случаев основную полезную работу делает catch-all default server, который вообще не трогает HTTP/1.0.

Почему на HTTP/1.0 стоит обратить внимание#

HTTP/1.0 заменили на HTTP/1.1 ещё в 1997 году. Все браузеры, выпущенные в этом веке, умеют как минимум 1.1, а по HTTPS согласуют HTTP/2 или HTTP/3. Поэтому, если access-лог заполняется запросами HTTP/1.0, их шлют не люди, читающие ваш сайт. Они приходят от клиентов трёх видов:

  • Массовые сканеры и сборщики баннеров. Инструменту, который прочёсывает всё адресное пространство IPv4, нужен самый короткий запрос, на который можно получить ответ. GET / HTTP/1.0 и пустая строка - именно он: заголовок Host не обязателен, управлять keep-alive не нужно, сервер сам закрывает соединение по окончании.
  • Старые или ленивые боты. Парсеры на древних HTTP-библиотеках, пробы уязвимостей, скопированные из десятилетних эксплойтов, и самодельные краулеры с жёстко прописанной строкой запроса.
  • Машины, которые общаются с машинами. Проверки состояния от балансировщиков нагрузки, утилиты для бенчмарков, скрипты мониторинга и - то, на чём попадаются чаще всего - reverse proxy, обращающиеся к серверу за ними.

Интересная особенность HTTP/1.0 в том, что заголовок Host необязателен. В HTTP/1.1 он обязателен, и nginx, и Apache отвечают 400 Bad Request на запрос HTTP/1.1 без него. Запрос HTTP/1.0 без Host допустим, поэтому серверу приходится угадывать, какой сайт отдать, и он выбирает сайт по умолчанию для этого адреса и порта. Так сканеры выясняют, что работает на IP-адресе, не зная ни одного его доменного имени: они спрашивают без имени и читают то, что отдаёт сайт по умолчанию, - страницу входа CMS, страницу ошибки фреймворка, версию стека в заголовке.

Этого уже достаточно. В тихом логе проще настроить оповещения, а приложение, которое не видит мусорных запросов, не тратит на них ни одного запроса к базе данных. Только не записывайте это в раздел "защита усилена".

Сначала логируйте, потом блокируйте#

Прежде чем менять поведение, выясните, кто на самом деле шлёт вам HTTP/1.0. Стандартный формат combined в обоих серверах уже это записывает, потому что строка запроса - метод, путь и протокол - это одно поле.

bash
# How many HTTP/1.0 requests, out of how many in total$ grep -c 'HTTP/1.0"' /var/log/nginx/access.log$ wc -l < /var/log/nginx/access.log# Which user agents send them, most frequent first$ grep 'HTTP/1.0"' /var/log/nginx/access.log \    | awk -F'"' '{print $6}' | sort | uniq -c | sort -rn | head -20# Which addresses send them$ grep 'HTTP/1.0"' /var/log/nginx/access.log \    | awk '{print $1}' | sort | uniq -c | sort -rn | head -20

Для Apache те же команды выполняются над /var/log/apache2/access.log в Debian и Ubuntu или над /var/log/httpd/access_log в семействе RHEL. Приём с awk -F'"' работает потому, что в формате combined user agent - третье поле в кавычках.

Что искать в выводе:

  • Ваши собственные адреса. 127.0.0.1, частный диапазон вроде 10.0.0.0/8 или адрес балансировщика нагрузки. Это машины, которыми вы управляете, и их блокировка что-нибудь сломает.
  • Узнаваемые инструменты. ApacheBench, название системы мониторинга, проверки состояния HAProxy, сервис проверки доступности. За каждым из них стоит чья-то работа.
  • Пустые или бессмысленные user agent с длинного хвоста адресов, обращающиеся к /, /.env, /wp-login.php, /phpmyadmin/ и подобному. Этот трафик вам и нужно убрать.

Наблюдайте неделю, а не один вечер. Еженедельные задания cron, ежемесячные отчёты и платёжный провайдер, который присылает callback дважды в год, рано или поздно проявятся, а неделя - самый короткий срок, который ловит большинство из них. В материале Logs worth keeping рассказано, как долго хранить access-логи, чтобы такую проверку можно было сделать задним числом.

В nginx запросы HTTP/1.0 можно писать в отдельный файл, и неделя наблюдений пройдёт безболезненно. Параметр if= у access_log пишет запрос в лог, только если переменная непустая и не равна 0:

/etc/nginx/conf.d/http10-log.conf
map $server_protocol $is_http10 {    default     0;    "HTTP/1.0"  1;}
nginx
server {    # ... your existing configuration ...    access_log /var/log/nginx/access.log combined;    access_log /var/log/nginx/http10.log combined if=$is_http10;}

В Apache то же делается через SetEnvIf, который умеет сопоставлять Request_Protocol напрямую, и CustomLog, срабатывающий только при установленной переменной:

/etc/apache2/conf-available/http10-log.conf
SetEnvIf Request_Protocol "^HTTP/1\.0$" old_httpCustomLog ${APACHE_LOG_DIR}/http10.log combined env=old_http

Включите конфигурацию командой a2enconf http10-log и перезагрузите Apache. В системах семейства RHEL положите файл в /etc/httpd/conf.d/ и замените ${APACHE_LOG_DIR} на logs.

Сначала catch-all default server#

Большинство тех, кто хочет "заблокировать HTTP/1.0", на самом деле хотят "перестать отвечать на запросы, в которых не указан ни один из моих сайтов". У этой задачи есть более чистое решение, работающее для любой версии протокола: default server, который отказывает во всём, так что к настоящему сайту доходят только запросы с реальным именем хоста.

нет Host или чужое имяизвестный HostКлиентлюбая версия HTTPnginxпорты 80 / 443Catch-alldefault_server, 444Ваш сайтserver_name совпалПриложение
Куда попадёт запрос, решает заголовок Host

В nginx это блок server с пометкой default_server на каждом порту. Запросы без заголовка Host и запросы с Host, не совпадающим ни с одним server_name, попадают сюда:

/etc/nginx/sites-available/00-catchall.conf
server {    listen 80 default_server;    listen [::]:80 default_server;    server_name _;    return 444;}server {    listen 443 ssl default_server;    listen [::]:443 ssl default_server;    server_name _;    ssl_reject_handshake on;}

444 - не настоящий статус HTTP. Он велит nginx закрыть соединение, ничего не отправив, так что клиент не узнаёт ничего, даже какое серверное ПО ему ответило. На порту 443 TLS-рукопожатие происходит до любого HTTP-запроса, поэтому одному return 444 сначала понадобился бы сертификат, который можно предъявить; ssl_reject_handshake on (nginx 1.19.4 и новее) отклоняет рукопожатие для не настроенных вами имён, и сертификат не нужен. В более старом nginx catch-all придётся выдать самоподписанный сертификат. Остальная схема, включая то, почему default_server может быть только у одного блока на порт, описана в руководстве по nginx reverse proxy.

В Apache ключевого слова default_server нет. По умолчанию используется первый загруженный VirtualHost для адреса и порта, поэтому Debian называет свой сайт по умолчанию 000-default.conf. Замените его содержимое виртуальным хостом, который запрещает всё:

/etc/apache2/sites-available/000-default.conf
<VirtualHost *:80>    ServerName default.invalid    <Location />        Require all denied    </Location>    ErrorLog ${APACHE_LOG_DIR}/default-error.log    CustomLog ${APACHE_LOG_DIR}/default-access.log combined</VirtualHost>

Apache не умеет молча обрывать соединение, как nginx, поэтому он возвращает 403 Forbidden. Домен верхнего уровня .invalid зарезервирован и никогда не разрешится, так что ни один настоящий запрос не совпадёт с ним по имени.

Когда catch-all настроен, запрос HTTP/1.0 без Host уже не получает ничего полезного. Для многих сайтов этого достаточно, а оставшийся трафик HTTP/1.0 - запросы, которые называют ваш домен, - достаточно безобиден, чтобы его не трогать. Если вы всё же хотите от него избавиться, читайте дальше.

Блокировка HTTP/1.0 в nginx#

$server_protocol содержит протокол, которым пользуется клиент: HTTP/1.0, HTTP/1.1, HTTP/2.0 или HTTP/3.0. Самая простая блокировка сравнивает его напрямую:

nginx
server {    listen 80;    server_name example.com www.example.com;    if ($server_protocol = HTTP/1.0) {        return 444;    }    # ... the rest of the site ...}

Привычное предупреждение об опасности if в nginx относится к if внутри location в сочетании с другими директивами. if на уровне server, содержащий только return, - один из случаев, которые документация nginx описывает как безопасные.

Сопоставляйте именно HTTP/1.0. Правило if ($server_protocol != HTTP/1.1) выглядит равнозначным, но это не так: оно блокирует и всех посетителей по HTTP/2 и HTTP/3, а на сайте с HTTPS это почти все.

Подход с map и исключениями

Простой if блокирует всех. Как только нужно исключение - сервер мониторинга, балансировщик нагрузки, 127.0.0.1, - удобнее map, который к тому же живёт в одном месте для всех сайтов. geo помечает доверенные адреса, а map объединяет две переменные в одно решение:

/etc/nginx/conf.d/http10-block.conf
geo $http10_trusted {    default        0;    127.0.0.1      1;    10.0.0.0/8     1;    203.0.113.25   1;   # monitoring host}map "$server_protocol:$http10_trusted" $http10_block {    default          0;    "HTTP/1.0:0"     1;}

Затем в каждом блоке server, который должен это применять:

nginx
if ($http10_block) {    return 444;}

Файлы в conf.d подключаются внутри контекста http, где только и могут находиться map и geo. Оба вычисляются лениво, только когда используется переменная, поэтому их определение ничего не стоит на сайтах, которые на них не ссылаются. Чтобы исключить user agent, а не адрес, добавьте $http_user_agent в источник map и используйте ключи-регулярные выражения, но помните, что user agent - это то, что клиент сам о себе сообщает.

444, 426 или 403

Какой ответ отправлять, зависит от того, кто, по вашим ожиданиям, будет его читать.

ОтветЧто видит клиентКогда использовать
444Соединение закрыто, ни одного байтаТрафик - мусор, и вы ему ничего не должны
426Upgrade Required с указанием причиныНастоящему клиенту может понадобиться подсказка
403ForbiddenНужен обычный, всем понятный отказ

426 Upgrade Required - вежливый ответ в духе стандарта: он сообщает, что запрос понят, но нужен более новый протокол. Спецификация HTTP предполагает, что заголовок Upgrade называет этот протокол, а в nginx add_header внутри if разрешён только на уровне location, поэтому полная версия выглядит так:

nginx
location / {    if ($http10_block) {        add_header Upgrade "HTTP/1.1" always;        add_header Connection "Upgrade" always;        return 426 "HTTP/1.1 or newer is required.\n";    }    # ... proxy_pass, try_files, etc ...}

always важен: без него add_header действует только для короткого списка кодов успеха и перенаправления, и заголовки молча пропадут из ответа 426. На практике для большинства сайтов правильный выбор - 444. Сканер не читает строки статуса, а законный клиент, получивший пустой ответ, выдаёт понятную ошибку (curl: (52) Empty reply from server), которая точно подскажет, где искать.

Блокировка HTTP/1.0 в Apache#

В Apache это можно сделать тремя способами. Выберите один: если смешать их, тому, кто будет отлаживать после вас, придётся несладко.

mod_rewrite по SERVER_PROTOCOL или THE_REQUEST

Эту версию показывает большинство руководств, и она работает и в основной конфигурации, и в .htaccess:

.htaccess or <VirtualHost>
RewriteEngine OnRewriteCond %{SERVER_PROTOCOL} =HTTP/1.0RewriteRule ^ - [F]

%{SERVER_PROTOCOL} содержит строку протокола, а префикс = делает сравнение точным строковым, а не регулярным выражением. [F] возвращает 403 Forbidden и останавливает обработку. Чтобы вместо этого отправить 426, используйте RewriteRule ^ - [R=426,L]: если статус, переданный в R, лежит вне диапазона 3xx, mod_rewrite отбрасывает подстановку и просто возвращает этот код.

%{THE_REQUEST} - это полная исходная строка запроса, например GET /index.php HTTP/1.0. Для нашей задачи сопоставление по ней равнозначно:

code
RewriteCond %{THE_REQUEST} HTTP/1\.0$RewriteRule ^ - [F]

Разница важна в других случаях: THE_REQUEST никогда не меняется предыдущими rewrite или внутренними перенаправлениями, поэтому именно эту переменную берут, когда блокируют по исходному запросу. Для проверки протокола SERVER_PROTOCOL читается понятнее. Исключения - это ещё одно условие: RewriteCond %{REMOTE_ADDR} !^10\., поставленное перед правилом, пропускает частный диапазон.

В .htaccess mod_rewrite работает, только если в AllowOverride для каталога указано FileInfo (или All). Если правила как будто ничего не делают, проверьте в первую очередь это, а затем - включён ли mod_rewrite вообще (a2enmod rewrite в Debian).

Require с выражением, без mod_rewrite

Парсер выражений Apache 2.4 тоже знает SERVER_PROTOCOL, так что то же правило можно записать через управление доступом, а не через перезапись:

<VirtualHost> or .htaccess
<If "%{SERVER_PROTOCOL} == 'HTTP/1.0' && ! -R '10.0.0.0/8'">    Require all denied</If>

-R проверяет, входит ли адрес клиента в подсеть. Можно также переиспользовать переменную old_http из раздела про логирование и написать Require not env old_http внутри блока <RequireAll> рядом с Require all granted. Обе формы возвращают 403. Такой подход читается легче, чем правила rewrite, и не взаимодействует с перезаписью, которую уже делает ваша CMS, а в .htaccess для WordPress или Laravel это настоящее преимущество.

Что делает и чего не делает HttpProtocolOptions

HttpProtocolOptions встречается почти в каждом ответе на этот вопрос, и HTTP/1.0 он не блокирует. Эта директива появилась в Apache 2.4.24 и управляет строгостью разбора строки запроса и заголовков. Значение по умолчанию - Strict LenientMethods Allow0.9, а вариант, который обычно советуют:

apache2.conf or <VirtualHost>
HttpProtocolOptions Strict Require1.0

Require1.0 отклоняет HTTP/0.9 - форму до 1996 года, где запрос состоит только из GET / без версии и заголовков. HTTP/1.0 и новее по-прежнему принимаются. Эту настройку стоит включить, ведь уже десятилетия никто законный не говорит на 0.9, но это другое решение другой (и меньшей) проблемы. Strict уже включён по умолчанию и заставляет Apache отвечать 400 на некорректные строки запроса и заголовки; не отключайте его, пока старый клиент не вынудит вас перейти на Unsafe.

Проверка через curl#

curl по запросу умеет говорить на HTTP/1.0, поэтому проверки делаются быстро. Запускайте их с машины, которой нет в списке исключений.

bash
# HTTP/1.0 with a Host header - should be blocked if you block 1.0$ curl --http1.0 -sv -o /dev/null http://example.com/# HTTP/1.0 with no Host header - should hit the catch-all$ curl --http1.0 -sv -o /dev/null -H "Host:" http://203.0.113.10/# HTTP/1.1, the control - should work normally$ curl --http1.1 -sv -o /dev/null http://example.com/# Over HTTPS, where browsers will negotiate HTTP/2$ curl --http1.0 -sv -o /dev/null https://example.com/

-H "Host:" с пустотой после двоеточия велит curl убрать заголовок, который он добавил бы сам, - это единственный способ заставить его отправить запрос без Host. Чего ожидать:

  • 444 от nginx выглядит как curl: (52) Empty reply from server.
  • 403 или 426 выглядит как < HTTP/1.1 403 Forbidden или что-то подобное в подробном выводе. Обратите внимание, что сервер указывает в строке статуса свою самую высокую версию, даже если запрос был 1.0; это нормально.
  • Запрос HTTP/1.1 без заголовка Host оба сервера отклоняют с 400 ещё до того, как сработают ваши правила.

Для самой "сырой" проверки, без клиентской логики между вами и сервером, напишите запрос вручную:

bash
$ printf 'GET / HTTP/1.0\r\n\r\n' | nc example.com 80

После тестов убедитесь, что исключения по-прежнему работают, выполнив первую команду с доверенного адреса, и что в логах заблокированные запросы отображаются с выбранным вами статусом. Всегда запускайте nginx -t или apachectl configtest перед перезагрузкой: опечатка в map выводит из строя все сайты на сервере, а не только тот, который вы правили.

Законные клиенты, которых можно сломать#

Этот раздел стоит прочитать дважды. Каждый из перечисленных клиентов по умолчанию или в распространённых конфигурациях всё ещё шлёт HTTP/1.0:

  • Сам nginx в роли прокси. proxy_http_version по умолчанию равен 1.0. Если nginx стоит перед другим nginx или Apache, бэкенд видит каждый запрос как HTTP/1.0, и блокировка протокола на бэкенде блокирует весь ваш сайт. Либо задайте proxy_http_version 1.1; на передней прокси (что вам и так нужно ради keep-alive и websockets), либо применяйте правило только на внешнем сервере.
  • Проверки состояния балансировщиков нагрузки. option httpchk в HAProxy отправляет запрос HTTP/1.0, пока вы не укажете версию. Заблокированная проверка состояния помечает бэкенд как недоступный, и балансировщик сам отключает ваш сайт.
  • ApacheBench. ab говорит только на HTTP/1.0. Ваш нагрузочный тест сообщит, что все запросы провалились, и вы час проведёте, подозревая приложение.
  • Мониторинг и проверки доступности. Некоторые старые проверки и плагины по-прежнему используют 1.0 по умолчанию. Ищите их в неделю наблюдений, а не узнавайте о них, когда замолкнут оповещения.
  • Очень старые консольные утилиты и встроенные клиенты. Старые сборки wget, принтеры, роутеры, прошивки IoT и скрипты, написанные под древние библиотеки. Если одно из этих устройств отправляет данные на webhook вашего сайта, оно перестанет работать без единого сообщения.
  • Webhook от старых систем. Платёжные, логистические и ERP-интеграции - то место, где живёт давний унаследованный код. Такие системы редки, и именно поэтому один вечер логов их не обнаружит.

Есть и более тонкий вариант первой проблемы: прокси, которым вы не управляете. Когда сайт стоит за CDN, фронтендом хостинг-провайдера или любым другим reverse proxy, вы видите ту версию HTTP, которую прокси использовал для обращения к вам, а не ту, что использовал посетитель. На тарифах RE:NODE для приложений и сайтов домен, подключённый через слот прокси тарифа, работает именно так: слот занимается сертификатом и передаёт настоящий адрес клиента в X-Forwarded-For. Проверьте $server_protocol (или %H в формате лога Apache) на своём сервере, прежде чем писать правило: если все запросы приходят с одной и той же версией, проверка протокола на этом уровне либо ничего не блокирует, либо блокирует всё. На выделенном сервере, где ваш nginx - первое, что стоит в интернете, версия в логе - это версия самого посетителя.

Что это не останавливает#

Если ясно понимать предел возможностей, разочарований будет меньше. Клиент, который отправляет HTTP/1.1 с корректным заголовком Host, проходит сквозь все правила из этой статьи, а современные фреймворки для сканирования, инструменты подбора учётных данных и парсеры именно так и делают. Проверка протокола отсеивает самый ленивый слой фонового шума; от целеустремлённого атакующего или хорошо написанного бота она не защищает.

Для трафика, который её обходит, нужны другие средства. Rate limiting ограничивает, как быстро любой отдельный клиент может обращаться к дорогим конечным точкам вроде форм входа. Fail2ban читает логи и банит адреса на уровне firewall после повторяющихся плохих запросов - а http10.log из раздела про логирование становится простым источником для jail, если вы хотите банить постоянных нарушителей сразу. Firewall rules that matter рассказывает, как закрыть все порты, которым не нужно быть открытыми, а это убирает куда больше поверхности атаки, чем всё, что делается на уровне HTTP. Для сайта на WordPress WordPress security hardening разбирает пути, которые эти сканеры обычно ищут.

Разумный порядок действий такой: настройте catch-all default server, неделю логируйте HTTP/1.0, добавьте исключения для всего своего, что там появится, заблокируйте через 444 в nginx или 403 в Apache, проверьте через curl и оставьте отдельный лог включённым, чтобы заметить день, когда что-то законное начнёт ломаться.

FAQ#

Повлияет ли блокировка HTTP/1.0 на поисковых роботов?

Нет. Googlebot, Bingbot и другие крупные краулеры используют HTTP/1.1 или HTTP/2. Если в неделю наблюдений в логе HTTP/1.0 появится малоизвестный краулер, который вам важен, добавьте для него исключение до блокировки.

Что использовать в nginx, 444 или 403?

444 - для трафика, в мусорности которого вы уверены, потому что клиент не получает ничего, даже подтверждения, какой сервер ответил. 403 или 426 - когда может пострадать настоящий клиент и его владельцу нужна читаемая ошибка, чтобы понять, что изменилось.

Блокирует ли HttpProtocolOptions Require1.0 HTTP/1.0 в Apache?

Нет, несмотря на название. Директива требует версию не ниже 1.0, то есть отклоняет HTTP/0.9. Чтобы отказывать самому HTTP/1.0, используйте условие mod_rewrite по %{SERVER_PROTOCOL} или блок <If> с Require all denied.

Можно ли блокировать запросы без заголовка Host, но разрешить HTTP/1.0 с ним?

Да, и часто это лучший компромисс. Catch-all default_server в nginx или первый загруженный VirtualHost с полным запретом в Apache получает все запросы, не называющие известный сайт, в том числе все запросы без Host. Запросы, называющие ваш домен, это не затрагивает, какой бы версии они ни были.

Почему после блокировки HTTP/1.0 упал весь мой сайт?

Почти наверняка потому, что перед изменённым вами сервером стоит другая прокси и общается с ним по HTTP/1.0 - nginx делает это по умолчанию. Уберите правило с бэкенда, примените его на внешней прокси и задайте там proxy_http_version 1.1.

Поможет ли это против DDoS-атаки?

Заметно - нет. Для флуда HTTP/1.0 не нужен, а к моменту, когда nginx закрывает соединение, оно уже принято. С объёмными атаками борются выше по цепочке, до веб-сервера, а не в его конфигурации.