Только что установленный Nginx говорит о себе больше необходимого: заголовок каждого ответа называет версию сервера, и страницы ошибок подписаны так же прямо. Про частоту обращений сервер не заботится - любой адрес стучится со скоростью скрипта, а лимит на размер тела запроса оказывается неожиданно тесным для реальных задач. Хорошая новость в том, что базовая защита собирается из штатных директив без пересборки и сторонних модулей. Ниже разобраны скрытие версии, лимиты размера тела, ограничение частоты запросов и числа одновременных соединений плюс фильтр ботов по заголовку User-Agent, с готовыми фрагментами и проверками через curl.

Что показывает наружу свежий Nginx и зачем это убирать

Узнать, что именно видит внешний мир, позволяет один запрос:

curl -sI http://example.com/ | grep -i "^server"
Server: nginx/1.30.5

Заголовок Server честно называет и имя, и версию. Та же подпись ждёт на страницах ошибок: достаточно открыть несуществующий адрес, и внизу страницы появится то же имя. Заголовок формирует сам Nginx даже при передаче запросов приложению: приложение за ним может отвечать чем угодно, а наружу уходит имя и версия входного узла. Для внутреннего стенда это мелочь, для публичного узла - бесплатная подсказка сканирующим скриптам: под конкретную ветку подбирается готовый набор проверок. Пример выше - просто образец, версия зависит от установленной сборки. На момент подготовки материала стабильная линейка - 1.30.x со свежим выпуском 1.30.5, линия разработки дошла до 1.31.6.

Сразу честная оговорка: сокрытие версии - гигиена, а не защита. Дыру закрывает обновление из пакетов дистрибутива, а не шторка на вывеске. Но и лишнюю информацию наружу выносить незачем, тем более что прячет её одна строка.

Скрытие версии сервера через server_tokens и границы метода

Директива server_tokens работает на уровнях http, server и location, по умолчанию включена. Значение off убирает версию из заголовка ответа и подписи страниц ошибок, остаётся короткое имя сервера:

http {
    server_tokens off;
}

Проверка после перечитывания конфигурации:

curl -sI http://example.com/ | grep -i "^server"
Server: nginx

Заодно проверяется подпись на странице ошибки:

curl -s http://example.com/no-such-page | grep -i nginx

В современных ветках директива принимает и произвольную строку - заголовок будет говорить то, что написал администратор. Есть и отдельное значение build с информацией о сборке. Полностью стереть имя сервера без пересборки или сторонних модулей не выйдет, да и смысла мало: сам факт Nginx вычисляется по отпечаткам ответов. Силы разумнее тратить на своевременные обновления пакетов - шторки не чинят дыры, а свежие сборки чинят.

Лимит размера тела запроса и поведение ошибки 413

Малоизвестный факт: свободная сборка ограничивает тело запроса одним мегабайтом по умолчанию. Значение задаёт client_max_body_size, при превышении клиент получает 413. Отсюда классическая загадка: форма загрузки спокойно принимает маленькие файлы и необъяснимо ломается на больших. Почему так выходит? Потому что глобальный лимит никто не расширил, а локальный для загрузок не описан. Правильная схема - умеренный общий лимит и широкий только там, где загрузка действительно нужна:

http {
    client_max_body_size 4m;
}

server {
    listen 80;
    server_name example.com;

    location /upload {
        client_max_body_size 64m;
        proxy_pass http://127.0.0.1:9000;
    }
}

Директива наследуется с уровня http вниз, поэтому расширение в конкретной локации не ослабляет остальной сайт. Значение 0 отключает проверку полностью - встречается в старых конфигах и открывает дорогу запросам любого размера, включая чужие архивы на диск сервера. Лимит - простая страховка от переростков, и отказываться от неё нет причин.

Проверяется настройка синтетическим файлом:

dd if=/dev/zero of=/tmp/test10m.bin bs=1M count=10
curl -F "file=@/tmp/test10m.bin" -o /dev/null -w "%{http_code}\n" http://example.com/upload

Код 413 подтверждает сработавший лимит, 200 - удачное расширение в локации. Попутное наблюдение из практики: браузеры не умеют нормально показывать ошибку 413, пользователь видит обрыв соединения вместо объяснения. Приложению стоит вернуть собственную страницу для превышенного лимита. И ещё одна пара, о которой забывают: большой файл по медленному каналу грузится долго, а client_body_timeout отсекает паузы между чтениями тела. Для локации загрузок разумно расширять и лимит, и таймаут, иначе один из них отменит пользу другого.

Ограничение частоты запросов через limit_req и запас burst

Модуль limit_req ограничивает частоту обработки запросов по выбранному ключу, обычно по адресу клиента. Схема называется дырявым ведром: обращения сверх разрешённой частоты скапливаются в очереди, выдавливаются с заданной скоростью, а перелив через край отбивается ошибкой. Зона описывается на уровне http:

limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;

Ключ $binary_remote_addr - бинарная форма адреса: 4 байта для IPv4 и 16 для IPv6, заметно экономнее текстового $remote_addr с его 7-15 байтами. Одно состояние в зоне на 64-битной платформе занимает 128 байт, поэтому мегабайта хватает примерно на восемь тысяч состояний. При заполнении зоны вытесняется давно не активное состояние, и счётчик такого ключа начнётся заново. Частота задаётся в запросах в секунду, а значения меньше одного запроса в секунду записываются в минутах: 30r/m означает половину запроса в секунду.

Применение к локациям:

server {
    location /login {
        limit_req zone=perip burst=5 nodelay;
    }
    location /api {
        limit_req zone=perip burst=20;
    }
}

По умолчанию burst равен нулю: всё сверх разрешённой частоты не отбивается, а замедляется. burst=5 разрешает пять запросов выстроиться в очередь, параметр nodelay выпускает очередь без задержки - пользователь не чувствует паузы, но проскочить быстрее очереди нельзя. С версии 1.15.7 доступен тонкий вариант delay: первые несколько сверхлимитных запросов идут сразу, остальные ждут.

Отбитым запросам по умолчанию отдаётся 503. Директива limit_req_status, появившаяся в версии 1.3.15, меняет код ответа: для частотных ограничений честнее смотрится 429. Для мягкого внедрения пригодится режим dry run с версии 1.17.1 - limit_req_dry_run on считает нарушителей, но не мешает им, идеальный способ подобрать цифры по живому трафику без жалоб пользователей.

Зон разрешено несколько, и лимиты складываются. Полная картина с двумя зонами выглядит так:

limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
limit_req_zone $server_name zone=perserver:10m rate=100r/s;

server {
    limit_req zone=perip burst=20 nodelay;
    limit_req zone=perserver burst=100;
}

Вторая зона страхует от сценария, когда много разных адресов вместе создают лавину: каждый посетитель укладывается в норму, а сервер уже перегружен. Подводный камень наследования: директивы с верхнего уровня действуют в location лишь при полном отсутствии своих - один limit_req в локации отменяет родительские. Отдельное напоминание про офисы за общим шлюзом: целый отдел делит один адрес и один лимит, и десять запросов в секунду на отдел оборачиваются волной отказов в разгар рабочего дня.

Лимит одновременных соединений и защита от медленных клиентов

Частота - половина заботы, вторая половина - число одновременно открытых соединений. Модуль limit_conn считает их по знакомой схеме зон:

limit_conn_zone $binary_remote_addr zone=connperip:10m;
limit_conn_zone $server_name zone=connperserver:10m;

server {
    limit_conn connperip 10;
    limit_conn connperserver 200;
}

Состояние соединения занимает 64 байта на 64-битной платформе, мегабайт вмещает порядка шестнадцати тысяч состояний. Отличие от limit_req заметное: при переполнении зоны ошибки начинают получать все новые запросы подряд, вытеснения по давности здесь нет. В HTTP/2 и HTTP/3 каждый одновременный запрос считается отдельным соединением, поэтому слишком тесный лимит прижимает современный браузер, который тянет с десяток ресурсов параллельно.

У модуля есть и свои ручки: limit_conn_status с версии 1.3.15 меняет код отказа, limit_conn_dry_run с версии 1.17.6 включает пробный режим без блокировок, limit_conn_log_level с версии 0.8.18 управляет журналом. Пробный режим полезен так же, как в limit_req: сначала посмотреть, кого заденет новый лимит, и только потом включать честную блокировку.

Против медленных клиентов стоят таймауты client_header_timeout и client_body_timeout, по умолчанию 60 секунд каждый. Первый ограничивает чтение заголовков целиком, второй - паузы между чтениями тела; не успевший клиент получает 408. На публичных узлах заголовочный таймаут снижают до 5-10 секунд: легальный браузер отправляет заголовки мгновенно, тянут время только скрипты.

Блокировка ботов по заголовку браузера и спискам адресов

Заголовок User-Agent представляется каждому запросу, и львиная доля мусорного трафика не утруждает себя подделкой. Заголовок превращается в переменную $http_user_agent, а дальше работает map:

map $http_user_agent $bad_bot {
    default      0;
    "~*semrush"  1;
    "~*ahrefs"   1;
    "~*mj12bot"  1;
    "~^$"        1;
}

server {
    if ($bad_bot) {
        return 403;
    }
}

Перечисленные маски - только образец, список пополняется по журналу доступа. Пустой заголовок отсекается маской ~^$: настоящие браузеры его не отправляют. Вместо 403 иногда возвращают 444 - код Nginx, который молча закрывает соединение без ответа, экономя байты на вежливости.

Честность прежде всего: заголовок подделывается одной опцией curl, поэтому фильтр отсеивает ленивых. Настойчивых останавливает многоуровневая схема. Адресные списки работают через модуль доступа:

location /admin {
    allow 10.0.0.0/24;
    allow 192.168.1.0/24;
    deny all;
    proxy_pass http://127.0.0.1:9000;
}

Правила проверяются по порядку до первого совпадения - привычная семантика списков доступа, ничего неожиданного. По журналам нарушителей вычисляет fail2ban с правилами на фаервол. Три уровня - фильтр по заголовку, белые списки для админки, автоматика по логам - держат узел в чистоте без ручной охоты.

Проверить фильтр просто:

curl -A "SemrushBot" -o /dev/null -w "%{http_code}\n" http://example.com/

Ответ 403 подтверждает, что фильтр жив.

Проверка результата и типичные ошибки внедрения

Список граблей, на которые наступают чаще всего, получился таким:

  1. лимит частоты повешен на весь сайт со статикой - браузер тянет десятки ресурсов параллельно и сам становится нарушителем;
  2. зона limit_req размером 1m на сервер с миллионом посетителей - состояния вытесняются быстрее, чем накапливаются, и лимит живёт своей жизнью;
  3. вся компания за корпоративным шлюзом делит один адрес - в середине рабочего дня начинается волна отказов;
  4. limit_req добавлен в location и молча отменил родительские лимиты уровня server;
  5. client_max_body_size выставлен в 0 ради удобства загрузок - сервер начинает принимать тело любого размера;
  6. server_tokens off при устаревшей версии создаёт ложное спокойствие - шторка не чинит дыры самой версии.

Проверить всё разом позволяет связка проверки конфигурации и плавной перезагрузки:

nginx -t && nginx -s reload

Ограничения применяются к новым запросам сразу после перечитывания конфигурации. Срабатывания видны в журнале ошибок: записи вида limiting requests появляются с указанием излишка, уровень задаётся директивой limit_req_log_level, по умолчанию error, а замедления пишутся уровнем ниже. Частотный лимит проверяется циклом коротких запросов:

for i in $(seq 1 15); do
    curl -s -o /dev/null -w "%{http_code} " http://example.com/login
done
echo

Чередование кодов успеха и отказов показывает очередь burst на работе: первые запросы проходят, поток сверх лимита отбивается.

Базовая защита не отменяет обновлений, резервных копий и настроенного фаервола, но закрывает дешёвые пути: версия больше не подсказывает готовые проверки, перегрузка отсекается на входе, а мусорные обходчики получают отказ без лишних разговоров. Через месяц после внедрения остаётся заглянуть в журнал ошибок и посчитать отбитые запросы - цифры сами объяснят, зачем всё это было нужно.