Справочник по проверкам безопасности Nginx. Для каждой проверки — уязвимый код, правильный код, kill chain и рекомендации.
proxy_pass используется переменная из пользовательского ввода ($arg_*, $http_*), атакующий может заставить nginx отправить запрос на произвольный внутренний сервис. Это позволяет сканировать внутреннюю сеть, обращаться к metadata-сервисам облачных провайдеров (169.254.169.254), красть credentials.GET /api/?target=169.254.169.254/latest/meta-data/location /api/ {
proxy_pass http://$arg_target;
}map $arg_target $backend {
default backend;
"service-a" service-a;
"service-b" service-b;
}
location /api/ {
proxy_pass http://$backend;
}$request_uri, $arg_*, $http_* содержат сырой пользовательский ввод. При подстановке в заголовки или редиректы атакующий может внедрить символы \r\n и добавить произвольные HTTP-заголовки, включая Set-Cookie для угона сессий.GET /redirect%0d%0aSet-Cookie:%20admin=1 HTTP/1.1location /redirect {
return 302 https://example.com$request_uri;
}
add_header X-Custom $http_referer;# $uri — уже нормализован nginx
location /redirect {
return 302 https://example.com$uri;
}
# Не используйте $http_* в заголовках$http_host — это сырой заголовок Host из запроса клиента. Атакующий может подставить произвольный Host, что ведёт к cache poisoning, password reset poisoning (ссылка для сброса пароля указывает на домен атакующего), обходу виртуальных хостов на бэкенде.Host: evil.comhttps://evil.com/reset?token=...proxy_set_header Host $http_host;
# $host — нормализован nginx, # соответствует server_name proxy_set_header Host $host;
location /files (без /) указывает на alias /var/www/uploads (без /), то запрос /files../etc/passwd преобразуется в /var/www/uploads/../etc/passwd = /etc/passwd. Атакующий читает произвольные файлы на сервере.GET /files../etc/shadowlocation /files {
alias /var/www/uploads;
}# Trailing slash и в location, и в alias
location /files/ {
alias /var/www/uploads/;
}root или alias содержит переменную ($host, $uri и др.), атакующий может манипулировать путём для чтения произвольных файлов через path traversal (../).location /dynamic {
root /var/www/$host;
}# Фиксированный путь
location /dynamic {
root /var/www/mysite;
}ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers DES-CBC3-SHA:RC4-SHA:AES128-SHA;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; ssl_prefer_server_ciphers on;
ssl nginx принимает на порту 443 обычный HTTP. Клиенты ожидают TLS на 443 — получается либо ошибка соединения, либо (хуже) передача данных в открытом виде по каналу, который считается защищённым.listen 443;
listen 443 ssl http2;
server {
listen 443 ssl;
# Нет ssl_dhparam
}# Генерация: openssl dhparam -out /etc/ssl/dhparam.pem 4096 ssl_dhparam /etc/ssl/dhparam.pem;
.git содержит полную историю репозитория: исходный код, пароли в коммитах, приватные ключи, конфигурации БД. Инструменты вроде git-dumper автоматически восстанавливают репозиторий по HTTP.GET /.git/HEAD → подтверждение наличия репозиторияgit-dumper https://target.com/.git/ repo/git log --all -p | grep -i password → утечка секретов из историиserver {
root /var/www/html;
# .git доступен по HTTP
}location ~ /\.git {
deny all;
return 404;
}.env содержит секреты приложения (DB_PASSWORD, API_KEY). .htpasswd — хэши паролей. .ssh/ — приватные SSH-ключи. Всё это доступно одним GET-запросом.server {
root /var/www/html;
# нет блокировки dotfiles
}# Блокируем все dotfiles кроме .well-known (ACME)
location ~ /\.(?!well-known) {
deny all;
}db.sql, backup.tar.gz, config.bak часто содержат полные дампы БД, конфигурации с паролями. Автоматические сканеры (nuclei, dirsearch) проверяют эти расширения в первую очередь.server {
root /var/www/html;
# db_dump.sql доступен
}location ~* \.(bak|sql|tar|gz|zip|swp|old|orig|dump)$ {
deny all;
}deny all; после allow nginx разрешает доступ ВСЕМ. Директивы allow/deny работают по принципу first match — без завершающего deny all белый список бесполезен.location /internal {
allow 10.0.0.0/8;
# нет deny all — доступ для всех!
}location /internal {
allow 10.0.0.0/8;
deny all;
}return обрабатывается на фазе rewrite, которая выполняется раньше фазы access (allow/deny). Поэтому return 200 в том же location что и deny all делает deny бесполезным — ответ отдаётся до проверки IP.location /admin {
allow 10.0.0.0/8;
deny all;
return 200 "admin"; # deny не работает!
}location /admin {
allow 10.0.0.0/8;
deny all;
proxy_pass http://admin_backend;
}
# Или используйте if + return в отдельном блокеlocation /admin {
proxy_pass http://backend;
}location /admin {
auth_basic "Admin Area";
auth_basic_user_file /etc/nginx/.htpasswd;
allow 10.0.0.0/8;
deny all;
proxy_pass http://backend;
}set_real_ip_from 0.0.0.0/0 доверяет заголовку X-Forwarded-For от ЛЮБОГО источника. Атакующий подставляет в X-Forwarded-For любой IP, обходя allow/deny правила, rate limiting и geo-блокировки.curl -H "X-Forwarded-For: 10.0.0.1" https://target.com/internalallow 10.0.0.0/8; deny all; → доступ к внутренним ресурсамset_real_ip_from 0.0.0.0/0; real_ip_header X-Forwarded-For;
# Только IP вашего балансировщика/CDN set_real_ip_from 10.0.0.0/8; set_real_ip_from 172.16.0.0/12; real_ip_header X-Forwarded-For; real_ip_recursive on;
resolver 8.8.8.8;
resolver 127.0.0.1 valid=30s; # Или корпоративный DNS-сервер resolver 10.0.0.53;
none в valid_referers разрешает запросы БЕЗ заголовка Referer. Любой инструмент (curl, скрипт) по умолчанию не отправляет Referer, полностью обходя защиту от хотлинкинга.valid_referers none server_names;
if ($invalid_referer) {
return 403;
}valid_referers server_names
*.example.com;
if ($invalid_referer) {
return 403;
}try_files $uri =404; nginx передаёт в PHP-FPM запросы к несуществующим .php файлам. Атакующий загружает картинку с PHP-кодом (shell.jpg), затем обращается к /uploads/shell.jpg/x.php — PHP-FPM исполняет код из картинки.shell.jpg содержит <?php system($_GET['c']); ?>GET /uploads/shell.jpg/.phplocation ~ \.php$ {
fastcgi_pass unix:/run/php/php-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}location ~ \.php$ {
try_files $uri =404; # ← обязательно!
fastcgi_pass unix:/run/php/php-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}rewrite ^/loop/(.*)$ /loop/$1 last;
# Перезапись не должна совпадать с входом rewrite ^/old/(.*)$ /new/$1 last;
ssl_certificate и ssl_certificate_key, nginx не сможет установить TLS-соединение. Клиенты получат ошибку SSL, а трафик может быть перехвачен при фолбэке на HTTP.server {
listen 443 ssl;
server_name example.com;
# ssl_certificate не указан!
}server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/ssl/certs/example.crt;
ssl_certificate_key /etc/ssl/private/example.key;
}ssl_stapling_verify on, но не указан ssl_trusted_certificate, nginx не сможет проверить ответ OCSP-сервера. Это позволяет MITM подменить OCSP-ответ и скрыть факт отзыва сертификата.server {
ssl_stapling on;
ssl_stapling_verify on;
# ssl_trusted_certificate не указан!
}server {
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/ssl/certs/ca-chain.pem;
}server_name, nginx использует первый найденный блок. Второй блок станет «мёртвым» — его настройки безопасности (auth, deny, SSL) никогда не применятся, что создаёт ложное чувство защиты.# Первый блок — без защиты
server {
listen 80;
server_name api.example.com;
location / { proxy_pass http://backend; }
}
# Второй блок — «защищённый», но никогда не сработает
server {
listen 80;
server_name api.example.com;
auth_basic "Restricted";
}server {
listen 80;
server_name api.example.com;
auth_basic "Restricted";
location / { proxy_pass http://backend; }
}proxy_redirect бэкенд может вернуть Location-заголовок с внутренним IP (например, 302 http://10.0.1.5:8080/login). Это раскрывает топологию внутренней сети — полезная информация для дальнейших атак.location / {
proxy_pass http://backend:3000;
# внутренний IP может утечь
}location / {
proxy_pass http://backend:3000;
proxy_redirect default;
}autoindex on показывает все файлы в директории. Атакующий может найти backup-файлы, конфигурации, логи, временные файлы, которые не предназначены для публичного доступа.autoindex on;
# Удалите autoindex on
# Или ограничьте доступ
location /public-files/ {
autoindex on;
allow 10.0.0.0/8;
deny all;
}server {
# нет security-заголовков
}add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header Content-Security-Policy "default-src 'self'" always;
add_header, ВСЕ заголовки из родительского server/http-блока сбрасываются. Security-заголовки (X-Frame-Options, HSTS) молча исчезают из ответа для этого location.server {
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
location /api/ {
add_header X-Api-Version "1.0";
# ↑ X-Frame-Options и X-Content-Type
# теперь НЕ отдаются для /api/!
}
}server {
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
location /api/ {
# Повторяем все нужные заголовки
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header X-Api-Version "1.0";
}
}server {
# нет limit_req / limit_conn
}limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=conn:10m;
server {
limit_conn conn 20;
location /api/ {
limit_req zone=api burst=20 nodelay;
}
}^~). Запрос может попасть в regex-блок без авторизации, минуя prefix-блок с auth_basic или deny. Для prefix-location nginx всегда выбирает самый длинный совпавший — это безопасно.# regex перехватит /api/admin раньше prefix-блока
location ~ /api {
proxy_pass http://backend;
}
location /api/admin {
auth_basic "Restricted";
proxy_pass http://backend;
}
# /api/admin попадёт в regex — без auth!# ^~ отдаёт приоритет prefix над regex
location ^~ /api/admin {
auth_basic "Restricted";
proxy_pass http://backend;
}
location ~ /api {
proxy_pass http://backend;
}server {
client_max_body_size 1m;
# ... 50 строк спустя ...
client_max_body_size 100m; # ← эта выигрывает
}server {
client_max_body_size 10m;
}http:// или https://) nginx откажется запускаться с ошибкой синтаксиса. Это не runtime-уязвимость, а ошибка конфигурации, которая блокирует запуск или перезагрузку сервера — опасна тем, что может помешать применить новый конфиг в production.location /api {
proxy_pass backend:8080;
}location /api {
proxy_pass http://backend:8080;
}if внутри location работает не так, как ожидается. Nginx обрабатывает if на этапе rewrite, и многие директивы (proxy_pass, fastcgi_pass) внутри if ведут себя непредсказуемо. Это документированная проблема — «if is evil» в документации nginx.location / {
if ($request_uri ~* "\.php$") {
proxy_pass http://php-backend;
}
proxy_pass http://default-backend;
}map $request_uri $backend {
~*\.php$ http://php-backend;
default http://default-backend;
}
location / {
proxy_pass $backend;
}server {
listen 443 ssl;
ssl_certificate /etc/ssl/cert.pem;
# ssl_stapling не включён
}server {
listen 443 ssl;
ssl_certificate /etc/ssl/cert.pem;
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/ssl/ca-chain.pem;
resolver 127.0.0.1;
}ssl_stapling_verify on nginx принимает OCSP-ответы без проверки подписи. Атакующий, контролирующий сеть, может подменить OCSP-ответ и скрыть отзыв скомпрометированного сертификата.server {
ssl_stapling on;
# ssl_stapling_verify не включён
}server {
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/ssl/ca-chain.pem;
}# Устаревшая форма SSL ssl on; listen 443;
# Современная форма listen 443 ssl;
client_max_body_size, worker_connections или буферов приводят к отказам в обслуживании легитимных пользователей: обрезаются загрузки файлов, отклоняются запросы, возникают ошибки 413/502.client_max_body_size 100; # 100 байт — слишком мало proxy_buffer_size 128; # 128 байт
client_max_body_size 10m; proxy_buffer_size 4k;
client_max_body_size 10g, proxy_buffers 256 1m) позволяют атакующему отправить огромный запрос, исчерпав память или диск сервера — это вектор для DoS-атаки.client_max_body_size 10g; # 10 ГБ — чрезмерно proxy_buffers 256 1m; # 256 МБ буферов
client_max_body_size 50m; proxy_buffers 8 16k;
rewrite-правил совпадают с одним URL, результат зависит от порядка и флагов. Без last/break правила применяются последовательно, и финальный URL может быть совершенно неожиданным — вплоть до открытого редиректа или обхода авторизации.rewrite ^/old/(.*)$ /new/$1; rewrite ^/new/(.*)$ /final/$1; # /old/page → /new/page → /final/page (двойная перезапись)
rewrite ^/old/(.*)$ /new/$1 last; rewrite ^/new/(.*)$ /final/$1 last;
X-Powered-By: PHP/8.2.0, раскрывающий точную версию ПО. Атакующий подбирает эксплойты под конкретную версию. Также могут утекать другие служебные заголовки с внутренней информацией.location ~ \.php$ {
fastcgi_pass unix:/var/run/php-fpm.sock;
# X-Powered-By: PHP/8.2.0 утекает клиенту
}location ~ \.php$ {
fastcgi_pass unix:/var/run/php-fpm.sock;
fastcgi_hide_header X-Powered-By;
# НЕ скрывайте Location — это сломает редиректы!
}default_server nginx использует первый server-блок для запросов на неизвестные домены. Это может раскрыть внутренний контент, SSL-сертификаты или бэкенды, не предназначенные для публичного доступа.server {
listen 80;
server_name app.example.com;
# Этот блок неявно ловит ВСЕ домены
}# Явный catch-all — возвращаем 444
server {
listen 80 default_server;
server_name _;
return 444;
}
server {
listen 80;
server_name app.example.com;
}proxy_pass содержит hostname (не IP), но нет resolver, nginx резолвит DNS только при старте. При смене IP бэкенда (failover, деплой, облачная миграция) трафик продолжит идти на старый IP — возможна потеря данных или запросы к чужому серверу.location /api {
proxy_pass http://backend.service.internal;
# DNS закеширован навсегда!
}resolver 127.0.0.1 valid=30s;
set $backend "backend.service.internal";
location /api {
proxy_pass http://$backend;
}stub_status показывает количество активных соединений, принятых запросов и другую статистику nginx. Атакующий может использовать эту информацию для оценки нагрузки, планирования DDoS и проверки эффективности атаки в реальном времени.location /nginx_status {
stub_status;
# Доступен всем!
}location /nginx_status {
stub_status;
allow 127.0.0.1;
deny all;
}merge_slashes off nginx не схлопывает двойные слеши: //admin и /admin считаются разными URI. Атакующий может обойти правила доступа (location /admin { deny all; }) запросом //admin. По умолчанию merge_slashes on — это безопасное поведение. Проблема возникает, если кто-то явно выключил эту директиву.http {
merge_slashes off;
# //admin и /admin — разные URI!
# deny all для /admin не сработает на //admin
}http {
merge_slashes on; # по умолчанию, безопасно
# Или не указывайте — on по умолчанию
}worker_processes nginx использует один рабочий процесс. Хотя nginx асинхронный и один процесс обрабатывает тысячи соединений, с одним воркером не используются все ядра CPU, что ограничивает пропускную способность. Также нет отказоустойчивости — падение единственного процесса остановит обработку всех запросов.# worker_processes не указан
events {
worker_connections 1024;
}worker_processes auto;
events {
worker_connections 1024;
}worker_rlimit_nofile меньше, чем 2 × worker_connections, nginx начнёт отклонять соединения под нагрузкой с ошибками «Too many open files» — классический DoS-вектор.worker_rlimit_nofile 1024;
events {
worker_connections 4096; # > rlimit!
}worker_rlimit_nofile 65535;
events {
worker_connections 8192;
}/metrics, /debug, /health с детальной информацией раскрывают внутреннюю архитектуру, версии ПО, имена бэкендов и метрики нагрузки. Это существенно упрощает разведку перед атакой.location /metrics {
proxy_pass http://prometheus-exporter;
}location /metrics {
proxy_pass http://prometheus-exporter;
allow 10.0.0.0/8;
allow 127.0.0.1;
deny all;
}server {
listen 80;
server_name example.com;
location / {
proxy_pass http://backend;
}
}server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}client_max_body_size равен 1m, но если он не задан явно, администратор может не знать лимит. Без явного ограничения сложнее контролировать DoS через загрузку больших файлов и соответствие требованиям безопасности.http {
# client_max_body_size не задан
# по умолчанию 1m — но явно ли это?
}http {
client_max_body_size 10m;
}server {
listen 443 ssl;
# ssl_session_tickets on по умолчанию
}server {
listen 443 ssl;
ssl_session_tickets off;
}Server: nginx/1.18.0 раскрывает точную версию. Атакующий может подобрать эксплойт для конкретной версии (CVE). Это information disclosure — не уязвимость сама по себе, но упрощает разведку.http {
# server_tokens on по умолчанию
# Ответ: Server: nginx/1.18.0
}http {
server_tokens off;
# Ответ: Server: nginx
}
# Или server_tokens build; для кастомной строкиserver {
listen 443 ssl;
}# nginx < 1.25.1:
server {
listen 443 ssl http2;
}
# nginx >= 1.25.1 (новый синтаксис):
server {
listen 443 ssl;
http2 on;
}location или server блок — чаще всего забытый код или незавершённая настройка. Пустой location наследует директивы родителя, что может привести к неожиданному поведению. Также это затрудняет чтение и аудит конфигурации.location /old-api {
# TODO: настроить позже
}
location /uploads {
}# Удалите пустые блоки или заполните:
location /old-api {
return 410;
}set или map, но нигде не используемые, засоряют конфигурацию и затрудняют аудит. Это может указывать на незавершённую настройку или рефакторинг.set $old_backend "http://legacy:8080";
# $old_backend нигде не используется
location / {
proxy_pass http://new-backend;
}# Удалите неиспользуемую переменную
location / {
proxy_pass http://new-backend;
}last, break, redirect или permanent nginx продолжает обработку следующих rewrite-правил. Это может привести к неожиданным каскадным перезаписям URL и усложняет отладку маршрутизации.rewrite ^/old/(.*)$ /new/$1; # Без флага — обработка продолжится
rewrite ^/old/(.*)$ /new/$1 last; # last — прекратить и начать поиск location заново
# Два одинаковых prefix — второй мёртвый
location /api {
proxy_pass http://backend-v1;
}
location /api {
# Этот блок никогда не сработает!
proxy_pass http://backend-v2;
}location /api {
proxy_pass http://backend-v2;
}
# Удалите дублирующийся locationerror_log off не отключает логирование — nginx записывает в файл с именем «off»! Для подавления логов используйте /dev/null. Но отключение error_log в production — плохая практика: без логов невозможно расследовать инциденты безопасности.error_log off; # Создаёт файл "off" в рабочей директории!
error_log /var/log/nginx/error.log; # Или для подавления (не рекомендуется): # error_log /dev/null crit;