Hardening de Nginx: La Guía para Amplificar tu Seguridad (con todo lo que puede salir mal si no lo haces) Cada línea de configuración por defecto de Nginx que ignoras es una puerta entreabierta. Esta guía muestra exactamente lo que cada una significa en la práctica — y cómo cerrarla, una por una.
Cada línea de configuración por defecto de Nginx que ignoras es una puerta entreabierta. Esta guía muestra exactamente lo que cada una significa en la práctica — y cómo cerrarla, una por una.
Nginx nació en 2004 con una propuesta diferente de Apache: ser ligero, rápido y manejar miles de conexiones simultáneas sin problemas. En 20 años se convirtió en el servidor web más usado del mundo entre los sitios del top 1 millón. Y, exactamente como Apache, su configuración predeterminada está diseñada para funcionar en cualquier lugar — no para ser segura en cualquier lugar.
La buena noticia es que Nginx tiene una configuración más limpia y centralizada que Apache. La mala noticia es que justamente por eso, cuando algo está mal, está mal en todos los sitios a la vez.
Esta guía sigue la misma lógica del post anterior sobre Apache: para cada elemento, explicamos qué hace la configuración predeterminada, qué puede hacer un atacante con eso (con escenarios reales), y cómo corregirlo con configuración lista para copiar.
Toda respuesta de Nginx incluye un header Server así:
Server: nginx/1.24.0
Y en páginas de error, aparece un pie de página igualmente revelador.
Agravante específico de Nginx: mucha gente ejecuta versiones compiladas con módulos de terceros (Brotli, ModSecurity, RTMP) que quedan atrás en las actualizaciones.
http {
server_tokens off;
}
Para esconder completamente el header (no solo la versión), usa el módulo headers-more:
more_clear_headers Server;
more_set_headers "Server: webserver";
Cuando Nginx pasa solicitudes a PHP-FPM, envía automáticamente una variable SERVERSOFTWARE con la versión de Nginx. Combinado con exposephp = On, filtra versión de ambos.
El mismo problema del item anterior, al doble. El atacante descubre versión de Nginx y de PHP con una única solicitud.
En php.ini:
expose_php = Off
En el bloque PHP de Nginx:
location ~ \.php$ {
fastcgi_param SERVER_SOFTWARE "webserver";
fastcgi_hide_header X-Powered-By;
}
La directiva fastcgihideheader impide que los headers provenientes del upstream se devuelvan al cliente. Úsala siempre.
Nginx acepta cualquier método HTTP que envíe el cliente y lo pasa a la aplicación backend.
DELETE /api/users/123 sin autenticación adecuadaserver {
if ($request_method !~ ^(GET|HEAD|POST)$) {
return 405;
}
}
Para APIs REST que necesitan PUT, DELETE, PATCH:
if ($request_method !~ ^(GET|HEAD|POST|PUT|DELETE|PATCH|OPTIONS)$) {
return 405;
}
Nginx no envía HSTS. Incluso en sitios HTTPS-only, el navegador hace la primera solicitud vía HTTP hasta recibir redirect.
SSL Stripping: un atacante en Wi-Fi público intercepta la primera solicitud HTTP, mantiene conexión HTTP con la víctima y HTTPS con el servidor real. La víctima nunca ve el candado, pero digita la contraseña de todas formas.
Con HSTS, el navegador se niega a hablar HTTP con el dominio después de la primera visita. El ataque deja de funcionar.
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
ADVERTENCIA CRÍTICA sobre always: sin este sufijo, el header solo se envía en respuestas exitosas y redirect, no en respuestas de error. Con always, se envía en todas. Usa siempre always en headers de seguridad en Nginx.
Advertencia sobre HSTS en sí: una vez aplicado con max-age largo, el navegador bloquea el dominio en HTTPS. Si el certificado expira, los usuarios quedan sin acceso. Comienza con max-age=300 (5 minutos), valida todo, luego aumenta.
Sin este header, cualquier sitio puede incrustar el tuyo dentro de un iframe.
Clickjacking: un atacante crea cualquier sitio (sorteo, juego) que carga tu panel admin dentro de un iframe invisible, con botones falsos superpuestos. La víctima — logueada en otra pestaña de tu sistema — hace clic en "ganar premio" y en realidad hace clic en "eliminar cuenta" en tu panel.
add_header X-Frame-Options "SAMEORIGIN" always;
Cuando recibe un archivo con Content-Type ambiguo, el navegador trata de adivinar el tipo real mirando el contenido (MIME sniffing).
Un atacante envía foto.png con contenido HTML/JavaScript dentro. Tú validas solo la extensión. Cuando otra víctima accede, el navegador mira el contenido, piensa "esto es HTML" y lo ejecuta. XSS almacenado sin ni siquiera necesitar burlar un filtro.
add_header X-Content-Type-Options "nosniff" always;
Cuando el usuario hace clic en un enlace externo, el navegador envía al destino la URL completa de origen.
Supón URLs como:
https://misistema.com/admin/usuarios?token=eyJhbGciOi...
https://misistema.com/reset-contrasena?key=abc123
https://mihospital.com/paciente/juan-silva-dni-12345/examenes
Cualquier enlace externo (o imagen de otro dominio, o píxel de rastreo) hace que la URL completa se filtre al destino. Tokens, IDs, datos sensibles. Sistemas de salud estadounidenses filtraron identificadores de pacientes a Facebook por esto exactamente.
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
URL completa en enlaces internos, solo el dominio en enlaces externos vía HTTPS, nada cuando se sale de HTTPS a HTTP.
Sin este header, cualquier página del sitio puede solicitar acceso a cámara, micrófono, geolocalización, USB, sensores.
Imagina un XSS en el sitio corporativo. Un atacante inyecta:
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
.then(stream => transmiteAlServidorDelAtacante(stream))
La víctima ve popup de "¿permitir cámara?" y como confía en el sitio, hace clic en permitir. Webcam y micrófono del director financiero transmitiendo en vivo.
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" always;
Sin CSP, el navegador ejecuta cualquier JavaScript que aparezca en la página, de cualquier origen.
XSS ha estado en el top 10 de OWASP por más de 20 años. Sin CSP, cualquier fallo se convierte en ejecución de JavaScript arbitrario con acceso a cookies y sesión. Con CSP bien configurado, incluso si el XSS existe, el navegador se niega a ejecutar el 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 es el header más difícil de configurar. La política anterior rompe sitios que usan Google Analytics, Google Fonts, jQuery vía CDN, etc. Siempre prueba primero con Content-Security-Policy-Report-Only, que reporta violaciones sin bloquear.
Sin estos headers, tu ventana comparte proceso con otras ventanas y los recursos pueden ser cargados por cualquier origen.
Después de Spectre y Meltdown (2018), se descubrió que JavaScript podía leer memoria de otros procesos vía canales secundarios de la CPU. La defensa: aislar procesos por origen.
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;
Advertencia COEP: require-corp rompe recursos de terceros que no envían CORP. Si incrustas YouTube, Maps, fuentes externas, deja COEP comentado.
Por defecto, Nginx no lista directorios — victoria por defecto. Pero el módulo está disponible y mucha gente lo habilita "solo para probar" y se olvida. Y más grave: Nginx no tiene .htaccess, así que todo debe venir del conf.
El listado ya ha filtrado backups de base de datos, código fuente (.git/), cargas privadas, documentos confidenciales. Incluso sin listado, el acceso directo a .env, .git/config, wp-config.php.bak es trivial para escáneres.
# Bloquea archivos y carpetas que comienzan con punto
location ~ /\. {
deny all;
access_log off;
log_not_found off;
return 404;
}
# Excepción para Let's Encrypt
location ^~ /.well-known/ {
allow all;
}
# Bloquea extensiones peligrosas
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;
}
# Bloquea archivos 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 qué return 404 en lugar de 403? 403 confirma al atacante que el archivo existe allí. 404 finge que no existe. Defensa en profundidad.
Si tienes location ~ \.php$ genérico, ejecuta PHP en cualquier ruta que termine en .php — incluyendo /uploads/shell.php.
Combina con cualquier fallo de carga — validación débil, MIME falsificado — y tienes carga de webshell. Un atacante envía shell.php, accede, y tiene ejecución de comandos con el usuario de PHP-FPM. Patrón de compromiso más común en WordPress, Joomla, Drupal en los últimos 15 años.
location ^~ /uploads/ {
location ~* \.(php|phtml|php3|php4|php5|pht|phar)$ {
deny all;
return 404;
}
}
El ^~ garantiza prioridad sobre regex locations, evitando que el PHP global recoja archivos dentro de uploads.
En muchas distros, Nginx viene con TLS 1.0 y 1.1 habilitados. Estos protocolos tienen vulnerabilidades conocidas (BEAST, FREAK, POODLE) y fueron oficialmente descontinuados por la IETF en 2021 (RFC 8996).
POODLE, BEAST, FREAK, ataques de downgrade. PCI-DSS requiere TLS 1.2+. Los navegadores modernos ya muestran aviso de "conexión no segura" para 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/tudominio.com.br/chain.pem;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
Usa el Mozilla SSL Configuration Generator para generar configuraciones personalizadas.
El valor predeterminado de Nginx es 1MB para body. Razonable, pero mucha gente lo aumenta a "100M" o "0" (ilimitado) sin pensar cuando aparece el error 413.
Un atacante envía solicitudes gigantes para agotar ancho de banda, memoria y espacio temporal en disco. Repitiéndolas, tumba el servidor.
Peor: timeouts largos (por defecto) dejan el servidor vulnerable a Slowloris — un ataque donde el cliente abre conexiones y envía bytes lentamente para congelar workers. Un único atacante tumba Nginx con un script Python de 20 líneas.
http {
client_max_body_size 2m;
client_body_buffer_size 128k;
client_header_buffer_size 1k;
large_client_header_buffers 4 8k;
# Timeouts cortos = anti-Slowloris
client_body_timeout 12;
client_header_timeout 12;
keepalive_timeout 15;
send_timeout 10;
}
# Aumenta solo en locations específicos:
location /api/upload {
client_max_body_size 50m;
}
Sin límite. El atacante envía tan rápido como puede.
/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;
}
}
}
Cuando Nginx es proxy inverso, reenvía los headers que la aplicación envía. Y las aplicaciones adoran filtrar:
X-Powered-By: Express
X-AspNet-Version: 4.0.30319
X-Runtime: 0.123456
X-Generator: Drupal 9
X-Drupal-Cache: HIT
El atacante descubre versión exacta del framework y busca CVEs. Headers como X-Drupal-Cache revelan estructura 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;
Úsalo generosamente. Es una de las mejores herramientas de Nginx.
Guarda en /etc/nginx/snippets/security-hardening.conf:
# ============================================================
# SECURITY HARDENING - Nginx
# Incluye en los servidores con:
# include snippets/security-hardening.conf;
# ============================================================
# --- Headers de seguridad HTTP (¡siempre '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;
# --- Bloqueo de métodos HTTP peligrosos ---
if ($request_method !~ ^(GET|HEAD|POST)$) {
return 405;
}
# --- Esconde headers filtrados por el 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;
# --- Bloqueo de archivos y carpetas que comienzan con punto ---
location ~ /\. {
deny all;
access_log off;
log_not_found off;
return 404;
}
location ^~ /.well-known/ {
allow all;
}
# --- Bloqueo de archivos sensibles por extensión ---
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;
}
# --- Bloqueo de archivos sensibles por nombre ---
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;
}
# --- Carpeta de cargas sin ejecución de PHP ---
location ^~ /uploads/ {
location ~* \.(php|phtml|php3|php4|php5|pht|phar)$ {
deny all;
return 404;
}
}
autoindex off;
Y el nginx.conf global:
http {
server_tokens off;
# Límites de tamaño
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;
}
Servidor HTTPS completo de ejemplo:
server {
listen 80;
listen [::]:80;
server_name tudominio.com.br www.tudominio.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 tudominio.com.br www.tudominio.com.br;
root /var/www/html;
index index.php index.html;
ssl_certificate /etc/letsencrypt/live/tudominio.com.br/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/tudominio.com.br/privkey.pem;
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/tudominio.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://tudominio.com.br
Herramientas: securityheaders.com, ssllabs.com/ssltest, Mozilla Observatory, y SentinelHub para monitoreo continuo.
server_tokens offexpose_php = Off en php.inifastcgiparam SERVERSOFTWARE sobrescritofastcgihideheader X-Powered-Byalways (¡probado con max-age corto antes!). bloqueados (con excepción .well-known)^~autoindex off en todos los locationsclientmaxbody_size definido (¡no 0!)proxyhideheader para fugas comunesSi leíste el post sobre Apache también, vale la pena destacar dónde los dos divergen a la hora de hacer hardening:
Nginx es más centralizado. No tiene .htaccess, así que toda configuración debe estar en el conf principal. Mejor para seguridad (menos sorpresas esparcidas), pero requiere acceso al servidor.
El always es una trampa de Nginx. Los headers sin always no aparecen en respuestas de error. Es la trampa más común en hardening de Nginx — crees que todo está perfecto hasta que alguien prueba una página 404 y descubre que la mitad de los headers desapareció.
Nginx tiene rate limiting nativo y excelente. Sintaxis mucho más simple que Apache.
El proxyhideheader es un superpoder. Especialmente importante porque Nginx es el servidor más común como proxy inverso para aplicaciones modernas (Node, Python, Go).
El listado de directorios está deshabilitado por defecto. Diferente de Apache. Victoria por defecto.
La polémica del if. En Nginx, if dentro de location puede causar comportamientos inesperados en algunos escenarios (vale la pena leer el famoso "If is Evil" en la wiki oficial). Para filtros simples como el de método HTTP que mostré aquí, es seguro.
El hardening sigue siendo un proceso, no un evento. ¿Aplicaste todo esto hoy? Excelente. Pero mañana alguien va a instalar un plugin que sobrescribe una config, alguien va a tocar el CSP para agregar un analytics y olvidará probar, alguien va a poner clientmaxbody_size 0 "solo para ver si resuelve el error de carga".
La única forma realista de mantener un servidor seguro a largo plazo es monitorear continuamente. Algo debe estar mirando, todos los días, si nada cambió a peor — si un header desapareció, si un puerto nuevo se abrió, si el certificado va a vencer, si una versión nueva tiene CVE crítico.
Es exactamente para eso que existe SentinelHub: escáner continuo de seguridad web, con alertas en tiempo real, descripciones en portugués, reportes listos para mostrar a la directiva, y detección de todo lo que estos dos posts cubrieron.
¿Te pareció útil? Comparte con el sysadmin de tu empresa. ¿Crees que debería haberlo hecho ayer? Probablemente sí.