все заметки
[ru / en]

Черные списки URL и User-Agent в NGINX

2025.05.10

Логи nginx растут как на дрожжах, боты устраивают набеги в попытке найти: .git, .env, wordpress, eval-stdin и т.д.

Хотя у меня на сервере нет ничего такого (и двигло неизвестное), но все равно захотелось огородиться.

Есть разные решения, но мы тут не для этого.

Итоговый конфиг чуть ниже, а далее некоторые недопонимая (это же nginx).

Для url все очевидно, обрабатываем location и выводим ошибку. А вот для обработки $http_user_agent (особо одаренные не подменяют UA на адекватный) нужно использовать if:

if ($http_user_agent ~* "curl|wget|zgrab|python|fasthttp|l9explore|AsyncHttpClient") {\n\treturn 403;\n}\nlocation ~ (^/\.|phpinfo|cgi-bin|eval-stdin|hello\.world|xmlrpc) {\n\treturn 403;\n}

Но сам nginx не советует использовать if, да и разный стиль немного раздражает, и регулярка может быть не такой компактной.

А если использовать свою страницу ошибки error_page, то появляется вторая проблема:
return из location выводит нашу страницу, а вот return из if выводит nginx страницу. Контекст виноват.

В то же время можно вывести текст и с помощью return: return 403 'forbidden'. Но здесь повторенье не мать ученья: два раза одна и та же константа. А если добавить проверку аргументов в строке запроса $query_string? Не вариант.

Плюс location может быть только в контексте server и location, что усложняет поддержку.

Вот map здесь отлично подходит. По трем причинам.

1. Обработка $uri и $http_user_agent будет одинаковая.

map var_nginx $var_new {\n\tdefault 0;\n\t[сравнение] значение; # ~ - регулярное выражение, ~* - регистр не учитывается\n}

2. Контекст http, а значит глобальная переменная. И есть аналог set:

map $nginx_version $message { # любая nginx переменная\n\tdefault 'text';\n}

Изменять это в одном месте приятнее.

3. map помогает исключить из логирования ненужные запросы в access_log с помощью if.

А исключить решил по статусу: 402 Payment Required - халявы нет.

Кстати, по умолчанию в nginx default_type = application/octet-stream. И для нормального отображения в браузере (кто рукастый) нужно поменять на text/plain.

Итого:

map $http_user_agent $bad_ua {\n\tdefault 0;\n\t- 1;\n\t~*curl 1;\n\t~*wget 1;\n\t~*zgrab 1;\n\t~*python 1;\n\t~*fasthttp 1;\n\t~*l9explore 1;\n\t~*AsyncHttpClient 1;\n}\n\nmap $uri $bad_uri {\n\tdefault 0;\n\t~/\. 1;      # .git, .env, .htaccess, etc\n\t~*wp- 1;     # wordpress\n\t~*xmlrpc 1;\n\t~*phpinfo 1;\n\t~*cgi-bin 1;\n\t~*eval-stdin 1;\n\t~*hello\.world 1;\n}\n\nmap $nginx_version $message {\n\tdefault 'keep calm and go offline';\n}\n\nmap $status $loggable {\n\tdefault 1;   # пишем в лог\n\t402 0;       # не пишем\n}\n\nserver {\n\t#...\n\taccess_log /var/log/nginx/access.log combined if=$loggable;\n\tdefault_type "text/plain; charset=utf-8";\n\tif ($bad_ua) {\n\t\treturn 402 $message;\n\t}\n\tif ($bad_uri) {\n\t\treturn 402 $message;\n\t}\n\t#...\n}

результат