Hardening do Nginx: O Guia para Amplificar sua Segurança (com tudo que pode dar errado se você não fizer) Cada linha de configuração padrão do Nginx que você ignora é uma porta entreaberta. Este guia mostra exatamente o que cada uma significa na prática — e como fechar, uma por uma.
Cada linha de configuração padrão do Nginx que você ignora é uma porta entreaberta. Este guia mostra exatamente o que cada uma significa na prática — e como fechar, uma por uma.
O Nginx nasceu em 2004 com uma proposta diferente do Apache: ser leve, rápido e lidar com milhares de conexões simultâneas sem suar. Em 20 anos virou o servidor web mais usado do mundo entre os sites do top 1 milhão. E, exatamente como o Apache, sua configuração padrão é projetada para funcionar em qualquer lugar — não para ser segura em qualquer lugar.
A boa notícia é que o Nginx tem uma configuração mais enxuta e centralizada que o Apache. A má notícia é que justamente por isso, quando algo está errado, está errado em todos os sites de uma vez.
Este guia segue a mesma lógica do post anterior sobre Apache: para cada item, explicamos o que a configuração padrão faz, o que um atacante consegue fazer com isso (com cenários reais), e como corrigir com configuração pronta pra copiar.
Toda resposta do Nginx inclui um header Server assim:
Server: nginx/1.24.0
E em páginas de erro, aparece um rodapé igualmente revelador.
Agravante específico do Nginx: muita gente roda versões compiladas com módulos de terceiros (Brotli, ModSecurity, RTMP) que ficam pra trás nas atualizações.
http {
server_tokens off;
}
Para esconder completamente o header (não apenas a versão), use o módulo headers-more:
more_clear_headers Server;
more_set_headers "Server: webserver";
Quando o Nginx passa requisições pro PHP-FPM, envia automaticamente uma variável SERVERSOFTWARE com a versão do Nginx. Combinado com exposephp = On, vaza versão dos dois.
Mesmo problema do item anterior, em estéreo. Atacante descobre versão do Nginx e do PHP com uma única requisição.
No php.ini:
expose_php = Off
No bloco PHP do Nginx:
location ~ \.php$ {
fastcgi_param SERVER_SOFTWARE "webserver";
fastcgi_hide_header X-Powered-By;
}
A diretiva fastcgihideheader impede que headers vindos do upstream sejam repassados ao cliente. Use sempre.
O Nginx aceita qualquer método HTTP que o cliente enviar e repassa pra aplicação backend.
DELETE /api/users/123 sem autenticação adequadaserver {
if ($request_method !~ ^(GET|HEAD|POST)$) {
return 405;
}
}
Para APIs REST que precisam de PUT, DELETE, PATCH:
if ($request_method !~ ^(GET|HEAD|POST|PUT|DELETE|PATCH|OPTIONS)$) {
return 405;
}
O Nginx não envia HSTS. Mesmo em sites HTTPS-only, o navegador faz o primeiro request via HTTP até receber redirect.
SSL Stripping: atacante em Wi-Fi público intercepta o primeiro request HTTP, mantém conexão HTTP com a vítima e HTTPS com o servidor real. A vítima nunca vê o cadeado, mas digita senha mesmo assim.
Com HSTS, o navegador se recusa a falar HTTP com o domínio depois da primeira visita. O ataque para de funcionar.
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
AVISO CRÍTICO sobre always: sem esse sufixo, o header só é enviado em respostas de sucesso e redirect, não em respostas de erro. Com always, é enviado em todas. Use sempre always em headers de segurança no Nginx.
Aviso sobre HSTS em si: uma vez aplicado com max-age longo, o navegador trava o domínio em HTTPS. Se o certificado vencer, usuários ficam sem acesso. Comece com max-age=300 (5 minutos), valide tudo, depois aumente.
Sem esse header, qualquer site pode embutir o seu dentro de um iframe.
Clickjacking: atacante cria site qualquer (sorteio, jogo) que carrega seu painel admin dentro de iframe invisível, com botões falsos sobrepostos. A vítima — logada em outra aba do seu sistema — clica em "ganhar prêmio" e na verdade clica em "excluir conta" no seu painel.
add_header X-Frame-Options "SAMEORIGIN" always;
Quando recebe arquivo com Content-Type ambíguo, o navegador tenta adivinhar o tipo real olhando o conteúdo (MIME sniffing).
Atacante envia foto.png com conteúdo HTML/JavaScript dentro. Você valida só a extensão. Quando outra vítima acessa, o navegador olha o conteúdo, pensa "isso é HTML" e executa. XSS armazenado sem nem precisar burlar filtro.
add_header X-Content-Type-Options "nosniff" always;
Quando usuário clica em link externo, o navegador envia ao destino a URL completa de origem.
Suponha URLs como:
https://meusistema.com/admin/usuarios?token=eyJhbGciOi...
https://meusistema.com/reset-senha?key=abc123
https://meuhospital.com/paciente/joao-silva-cpf-12345/exames
Qualquer link externo (ou imagem de outro domínio, ou pixel de tracking) faz a URL inteira vazar pro destino. Tokens, IDs, dados sensíveis. Sistemas de saúde americanos vazaram identificadores de pacientes pro Facebook por causa exatamente disso.
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
URL completa em links internos, só o domínio em links externos via HTTPS, nada quando saindo de HTTPS pra HTTP.
Sem esse header, qualquer página do site pode pedir acesso a câmera, microfone, geolocalização, USB, sensores.
Imagine um XSS no site corporativo. Atacante injeta:
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
.then(stream => transmiteParaServidorDoAtacante(stream))
A vítima vê popup de "permitir câmera?" e como confia no site, clica em permitir. Webcam e microfone do diretor financeiro transmitindo ao vivo.
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" always;
Sem CSP, o navegador executa qualquer JavaScript que aparecer na página, de qualquer origem.
XSS está há mais de 20 anos no top 10 da OWASP. Sem CSP, qualquer falha vira execução de JavaScript arbitrário com acesso a cookies e sessão. Com CSP bem configurada, mesmo que o XSS exista, o navegador se recusa a executar o script malicioso.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always;
CSP é o header mais difícil de configurar. A política acima quebra sites que usam Google Analytics, Google Fonts, jQuery via CDN, etc. Sempre teste primeiro com Content-Security-Policy-Report-Only, que reporta violações sem bloquear.
Sem esses headers, sua janela compartilha processo com outras janelas e recursos podem ser carregados por qualquer origem.
Após Spectre e Meltdown (2018), descobriu-se que JavaScript podia ler memória de outros processos via side channels da CPU. A defesa: isolar processos por origem.
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
# add_header Cross-Origin-Embedder-Policy "require-corp" always;
Aviso COEP: require-corp quebra recursos de terceiros que não enviam CORP. Se embute YouTube, Maps, fontes externas, deixe COEP comentado.
Por padrão, o Nginx não lista diretórios — vitória default. Mas o módulo está disponível e muita gente habilita "só pra testar" e esquece. E mais grave: o Nginx não tem .htaccess, então tudo precisa vir do conf.
Listagem já vazou backups de banco, código fonte (.git/), uploads privados, documentos confidenciais. Mesmo sem listagem, acesso direto a .env, .git/config, wp-config.php.bak é trivial pra scanners.
# Bloqueia arquivos e pastas começando com ponto
location ~ /\. {
deny all;
access_log off;
log_not_found off;
return 404;
}
# Exceção para Let's Encrypt
location ^~ /.well-known/ {
allow all;
}
# Bloqueia extensões perigosas
location ~* \.(bak|backup|swp|old|sql|sql\.gz|tar|tar\.gz|zip|log|env|ini|conf|config|yml|yaml|lock)$ {
deny all;
access_log off;
log_not_found off;
return 404;
}
# Bloqueia arquivos específicos
location ~* (composer\.(json|lock)|package(-lock)?\.json|wp-config\.php|configuration\.php|web\.config)$ {
deny all;
access_log off;
log_not_found off;
return 404;
}
Por que return 404 em vez de 403? 403 confirma ao atacante que o arquivo existe ali. 404 finge que não existe. Defesa em profundidade.
Se você tem location ~ \.php$ genérico, ele executa PHP em qualquer caminho terminado em .php — incluindo /uploads/shell.php.
Combine com qualquer falha de upload — validação fraca, MIME forjado — e você tem upload de webshell. Atacante envia shell.php, acessa, e tem execução de comandos com o usuário do PHP-FPM. Padrão de comprometimento mais comum em WordPress, Joomla, Drupal há 15 anos.
location ^~ /uploads/ {
location ~* \.(php|phtml|php3|php4|php5|pht|phar)$ {
deny all;
return 404;
}
}
O ^~ garante prioridade sobre regex locations, evitando que o PHP global pegue arquivos dentro de uploads.
Em muitas distros, Nginx vem com TLS 1.0 e 1.1 habilitados. Esses protocolos têm vulnerabilidades conhecidas (BEAST, FREAK, POODLE) e foram oficialmente descontinuados pelo IETF em 2021 (RFC 8996).
POODLE, BEAST, FREAK, downgrade attacks. PCI-DSS exige TLS 1.2+. Browsers modernos já mostram aviso de "conexão não segura" pra TLS 1.0/1.1.
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_ecdh_curve X25519:secp384r1:secp256r1;
ssl_session_timeout 1d;
ssl_session_cache shared:NginxSSL:50m;
ssl_session_tickets off;
# OCSP Stapling
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/seudominio.com.br/chain.pem;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
Use o Mozilla SSL Configuration Generator para gerar configurações sob medida.
Padrão do Nginx é 1MB pra body. Razoável, mas muita gente aumenta pra "100M" ou "0" (ilimitado) sem pensar quando bate erro 413.
Atacante manda requisições gigantes pra exaurir banda, memória e disco temporário. Repetindo, derruba o servidor.
Pior: timeouts longos (padrão) deixam o servidor vulnerável a Slowloris — ataque onde cliente abre conexões e envia bytes lentamente pra travar workers. Um único atacante derruba o Nginx com script Python de 20 linhas.
http {
client_max_body_size 2m;
client_body_buffer_size 128k;
client_header_buffer_size 1k;
large_client_header_buffers 4 8k;
# Timeouts curtos = anti-Slowloris
client_body_timeout 12;
client_header_timeout 12;
keepalive_timeout 15;
send_timeout 10;
}
# Aumente apenas em locations específicas:
location /api/upload {
client_max_body_size 50m;
}
Sem limite. Atacante manda na velocidade que conseguir.
/loginhttp {
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;
server {
limit_conn conn_per_ip 20;
location /login {
limit_req zone=login burst=3 nodelay;
}
location /api/ {
limit_req zone=api burst=20 nodelay;
}
}
}
Quando o Nginx é proxy reverso, repassa os headers que a aplicação envia. E aplicações adoram vazar:
X-Powered-By: Express
X-AspNet-Version: 4.0.30319
X-Runtime: 0.123456
X-Generator: Drupal 9
X-Drupal-Cache: HIT
Atacante descobre versão exata do framework e busca CVEs. Headers como X-Drupal-Cache revelam estrutura interna.
proxy_hide_header X-Powered-By;
proxy_hide_header X-AspNet-Version;
proxy_hide_header X-AspNetMvc-Version;
proxy_hide_header X-Runtime;
proxy_hide_header X-Generator;
proxy_hide_header X-Drupal-Cache;
proxy_hide_header X-Drupal-Dynamic-Cache;
proxy_hide_header Server;
fastcgi_hide_header X-Powered-By;
Use generosamente. É uma das melhores ferramentas do Nginx.
Salve em /etc/nginx/snippets/security-hardening.conf:
# ============================================================
# SECURITY HARDENING - Nginx
# Inclua nos servers com:
# include snippets/security-hardening.conf;
# ============================================================
# --- Headers de segurança HTTP (sempre 'always'!) ---
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
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 Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
# --- Bloqueio de métodos HTTP perigosos ---
if ($request_method !~ ^(GET|HEAD|POST)$) {
return 405;
}
# --- Esconde headers vazados pelo upstream ---
proxy_hide_header X-Powered-By;
proxy_hide_header X-AspNet-Version;
proxy_hide_header X-AspNetMvc-Version;
proxy_hide_header X-Runtime;
proxy_hide_header X-Generator;
proxy_hide_header X-Drupal-Cache;
proxy_hide_header X-Drupal-Dynamic-Cache;
fastcgi_hide_header X-Powered-By;
# --- Bloqueio de arquivos e pastas começando com ponto ---
location ~ /\. {
deny all;
access_log off;
log_not_found off;
return 404;
}
location ^~ /.well-known/ {
allow all;
}
# --- Bloqueio de arquivos sensíveis por extensão ---
location ~* \.(bak|backup|swp|old|sql|sql\.gz|tar|tar\.gz|zip|log|env|ini|conf|config|yml|yaml|lock)$ {
deny all;
access_log off;
log_not_found off;
return 404;
}
# --- Bloqueio de arquivos sensíveis por nome ---
location ~* (composer\.(json|lock)|package(-lock)?\.json|wp-config\.php|configuration\.php|web\.config)$ {
deny all;
access_log off;
log_not_found off;
return 404;
}
# --- Pasta de uploads sem execução de PHP ---
location ^~ /uploads/ {
location ~* \.(php|phtml|php3|php4|php5|pht|phar)$ {
deny all;
return 404;
}
}
autoindex off;
E o nginx.conf global:
http {
server_tokens off;
# Limites de tamanho
client_max_body_size 2m;
client_body_buffer_size 128k;
client_header_buffer_size 1k;
large_client_header_buffers 4 8k;
# Timeouts (anti-Slowloris)
client_body_timeout 12;
client_header_timeout 12;
keepalive_timeout 15;
send_timeout 10;
# Rate limiting
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;
# SSL/TLS
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_ecdh_curve X25519:secp384r1:secp256r1;
ssl_session_timeout 1d;
ssl_session_cache shared:NginxSSL:50m;
ssl_session_tickets off;
}
Server HTTPS completo de exemplo:
server {
listen 80;
listen [::]:80;
server_name seudominio.com.br www.seudominio.com.br;
location ^~ /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name seudominio.com.br www.seudominio.com.br;
root /var/www/html;
index index.php index.html;
ssl_certificate /etc/letsencrypt/live/seudominio.com.br/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/seudominio.com.br/privkey.pem;
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/seudominio.com.br/chain.pem;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
include snippets/security-hardening.conf;
limit_conn conn_per_ip 20;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param SERVER_SOFTWARE "webserver";
include fastcgi_params;
fastcgi_hide_header X-Powered-By;
}
location = /login {
limit_req zone=login burst=3 nodelay;
try_files $uri /index.php?$query_string;
}
location /api/ {
limit_req zone=api burst=20 nodelay;
try_files $uri /index.php?$query_string;
}
}
sudo nginx -t
sudo systemctl reload nginx
curl -I https://seudominio.com.br
Ferramentas: securityheaders.com, ssllabs.com/ssltest, Mozilla Observatory, e SentinelHub para monitoramento contínuo.
server_tokens offexpose_php = Off no php.inifastcgiparam SERVERSOFTWARE sobrescritofastcgihideheader X-Powered-Byalways (testado com max-age curto antes!). bloqueados (com exceção .well-known)^~autoindex off em todos os locationsclientmaxbody_size definido (não 0!)proxyhideheader para vazamentos comunsSe você leu o post sobre Apache também, vale destacar onde os dois divergem na hora de fazer hardening:
O Nginx é mais centralizado. Não tem .htaccess, então toda configuração precisa estar no conf principal. Melhor pra segurança (menos pegadinhas espalhadas), mas exige acesso ao servidor.
O always é uma armadilha do Nginx. Headers sem always não aparecem em respostas de erro. É a pegadinha mais comum em hardening de Nginx — você acha que tudo está perfeito até alguém testar uma página 404 e descobrir que metade dos headers sumiu.
O Nginx tem rate limiting nativo e excelente. Sintaxe muito mais simples que o Apache.
O proxyhideheader é um superpoder. Especialmente importante porque o Nginx é o servidor mais comum como proxy reverso pra aplicações modernas (Node, Python, Go).
Listagem de diretórios é desabilitada por padrão. Diferente do Apache. Vitória default.
A polêmica do if. No Nginx, if dentro de location pode causar comportamentos inesperados em alguns cenários (vale a pena ler o famoso "If is Evil" na wiki oficial). Para filtros simples como o de método HTTP que mostrei aqui, é seguro.
Hardening continua sendo um processo, não um evento. Você aplicou tudo isso hoje? Ótimo. Mas amanhã alguém vai instalar um plugin que sobrescreve uma config, alguém vai mexer no CSP pra adicionar um analytics e esquecer de testar, alguém vai colocar clientmaxbody_size 0 "só pra ver se resolve o erro do upload".
A única forma realista de manter um servidor seguro a longo prazo é monitorar continuamente. Algo precisa estar olhando, todos os dias, se nada mudou pra pior — se um header sumiu, se uma porta nova abriu, se o certificado vai vencer, se uma versão nova tem CVE crítica.
É exatamente pra isso que existe o SentinelHub: scanner contínuo de segurança web, com alertas em tempo real, descrições em português, relatórios prontos pra mostrar pra diretoria, e detecção de tudo que esses dois posts cobriram.
Achou útil? Compartilha com o sysadmin da sua empresa. Achou que ele deveria ter feito isso ontem? Provavelmente sim.