· 8 min de lectura

Mi portfolio estático devolvía 503 y la culpa era mía

Cómo tengo nginx en una VM de 840 MiB: catch-all con 444, ssl_reject_handshake, cabeceras, un rate limit que respondía 503 y un hueco que encontré escribiendo este artículo.

Tornos de acceso de una estación de metro de Chicago cerrados tras una persiana metálica de rejilla
Lo que ve un escáner que pregunta por un host que no existe en mi servidor. Foto: «Chicago Pedway 81: Closed Washington Station» de Aaron Volkening · CC BY 2.0, recortada y redimensionada

Mi web es un portfolio estático: HTML y CSS precomprimidos con brotli y gzip que sirve nginx desde una VM de Azure con unos 840 MiB de RAM. Ahí no hay nada que pueda estar “temporalmente no disponible”. Aun así, en los logs de las últimas dos semanas hay más de 2.000 respuestas 503, con días de 150 a 400.

El culpable es el limit_req que tengo puesto, que por defecto contesta 503. Para ver quién se las llevaba:

sudo awk '$9==503{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head

Las cinco primeras IPs eran todas de rangos de Google Cloud, con entre 116 y 325 respuestas cada una. Escáneres alquilados por horas, no visitas. O sea, que llevaba tiempo con un rate limit que en los logs no se distinguía de un servidor caído. Ya puestos, voy a repasar todo lo que tengo en nginx, empezando por lo que hace con el tráfico que ni siquiera es para mí, que es casi todo.

Lo que llega preguntando por cualquier cosa

Los escáneres recorren rangos de IPs enteros, así que la mayoría de peticiones llegan con el Host puesto a la IP de la VM o a algo inventado. Si no defines un servidor por defecto, nginx usa el primer server que encuentra para ese puerto, y tu web acaba contestando a nombres que no son suyos.

La solución viene en la propia documentación de server_name de nginx, que pone de ejemplo un catch-all con server_name _ y return 444. El guion bajo no tiene nada de especial, es solo un nombre inválido que nunca va a coincidir con un dominio real. El 444 tampoco es un código HTTP de verdad: nginx cierra la conexión sin mandar nada.

server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name _;
    return 444;
}

Desde fuera se ve así:

$ curl -v -H "Host: noexiste.example" http://arnauserver.me/
...
curl: (52) Empty reply from server

Lo prefiero a un 404. Un 404 ya es una respuesta, con sus cabeceras y su cuerpo, y a un escáner que busca /.env o /.git/config en la IP pelada no le debo ni eso. En los logs siguen apareciendo, pero no se llevan nada.

En el 443 hay que cortar antes

Con HTTPS el truco anterior se queda corto. Para llegar a devolver un 444, nginx tiene que completar antes el handshake TLS, y para eso necesita un certificado. Si el servidor por defecto usa el de arnauserver.me, cualquiera que se conecte a la IP se lleva el certificado y, de regalo, el nombre del dominio.

Para eso está ssl_reject_handshake, disponible desde nginx 1.19.4. Rechaza el handshake en ese bloque server, así que no hace falta configurarle ningún certificado. Con SNI desconocido o sin SNI, la conexión muere ahí.

$ openssl s_client -connect arnauserver.me:443 -servername noexiste.example </dev/null
...
SSL routines:ssl3_read_bytes:tlsv1 unrecognized name ... SSL alert number 112

La alerta 112 es unrecognized_name: el servidor dice que no conoce ese nombre y no llega a enseñar ningún certificado.

El return 444 que creía que sobraba

Mi bloque del 443 era este:

server {
    listen 443 ssl http2 default_server;
    listen [::]:443 ssl http2 default_server;
    ssl_reject_handshake on;
}

Sin server_name y sin return, porque si no hay TLS no hay petición, ¿no? Pues no. En el ticket 2463 del trac de nginx alguien se quejaba de peticiones que se colaban pese a ssl_reject_handshake, y Maxim Dounin le explicaba por qué. La directiva solo actúa sobre los handshakes de ese bloque. Si el handshake se hace contra otro servidor (con un SNI válido) y la petición trae otro Host, esa petición acaba en el servidor por defecto. Él mismo sugería añadir un return en ese bloque.

Se reproduce fácil, porque curl manda como SNI el nombre de la URL y como Host lo que tú le digas. Lo probé mientras escribía esto, convencido de que no iba a pasar nada:

$ curl -s https://arnauserver.me/ -H "Host: otro.example" | tail -3
<p><em>Thank you for using nginx.</em></p>
</body>
</html>

Un 200 con la página de bienvenida de nginx que trae el paquete de Ubuntu. No es grave, pero confirma a cualquiera que pregunte que detrás hay un nginx de Ubuntu sin tocar en ese rincón, justo lo que el resto de la configuración intenta esconder. El arreglo son dos líneas:

server {
    listen 443 ssl http2 default_server;
    listen [::]:443 ssl http2 default_server;
    ssl_reject_handshake on;
    server_name _;
    return 444;
}

Después de nginx -t y un reload, la misma petición ya no recibe nada: curl se queja de que el stream HTTP/2 se cerró con PROTOCOL_ERROR, que es lo que hace un 444 cuando va por HTTP/2.

Cabeceras, y la trampa de add_header

server_tokens off quita la versión de nginx de la cabecera Server y de las páginas de error. Como defensa es poca cosa, pero tampoco veo motivo para anunciarle la versión exacta a quien escanea. Encima van estas, en conf.d/hardening.conf:

server_tokens off;

add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; ..." always;

(La Permissions-Policy real es más larga; he recortado la lista y la CSP para que se lea.)

El always no está de adorno. Según la documentación del módulo de cabeceras, add_header solo añade la cabecera cuando el código es 200, 201, 204, 206, 301, 302, 303, 304, 307 o 308. Sin always, un 404 o un 429 salen sin CSP ni HSTS.

La otra trampa es la herencia. Las directivas add_header se heredan del nivel superior solo si en el nivel actual no hay ninguna. Basta un location con su propio add_header Cache-Control ... para que ahí desaparezcan todas las cabeceras de seguridad, sin ningún aviso. A mí me pasó con los subdominios: cada uno lleva su propia CSP, y al ponerla dejaban de heredar el resto. Lo resolví con un fichero aparte, snippets/arnau-headers-common.conf, que incluyo en cada bloque que defina cabeceras propias. nginx 1.29.3 añadió add_header_inherit merge para esto mismo, pero Ubuntu 24.04 trae la 1.24.0. Para comprobarlo, mejor pedir una ruta que no exista, que es donde se nota lo del always:

$ curl -sI https://arnauserver.me/no-existe | grep -iE 'strict-transport|content-security'
strict-transport-security: max-age=63072000; includeSubDomains
content-security-policy: default-src 'self'; script-src 'self'; ...

30 peticiones por minuto

El límite es por IP, con $binary_remote_addr como clave. Mi cifra, por cierto, es literalmente el ejemplo de la documentación de limit_req: para menos de una petición por segundo se usa r/m, y media petición por segundo son 30r/m. La misma página dice que una zona de 1 MB guarda unos 16.000 estados de 64 bytes, u 8.000 en plataformas de 64 bits. Yo le di 10 MB, que es muchísimo más de lo que necesito, pero en una máquina de 840 MiB tampoco se nota.

El burst, en cambio, sí importa. nginx no cuenta peticiones por minuto, va al milisegundo: con 30r/m, una segunda petición antes de que pasen dos segundos ya se rechaza. Una página que pide su HTML, su CSS y unas imágenes se comería el límite ella sola. Con burst se permite una ráfaga, y con nodelay las peticiones de esa ráfaga se sirven de inmediato en lugar de espaciarse. El artículo de rate limiting del blog de nginx lo explica bien con el símil del cubo que gotea. Lo mío queda así:

limit_req_zone $binary_remote_addr zone=general:10m rate=30r/m;
limit_req_zone $binary_remote_addr zone=chat:1m rate=6r/m;

server {
    location / {
        limit_req zone=general burst=20 nodelay;
    }
    location = /api/chat {
        limit_req zone=chat burst=3 nodelay;
        limit_req_status 429;
        proxy_pass http://127.0.0.1:8083/api/chat;
    }
}

Y ahí está la gracia: el limit_req_status 429 solo lo puse en el chat, que es el endpoint que cuesta dinero porque llama a una API. En el resto se quedó el valor por defecto, 503, y por eso los escáneres aparecen en los logs como si el servidor estuviera fallando. Con 429 (Too Many Requests) el código dice lo que pasa y se separa una cosa de la otra. Me parece un mal valor por defecto para un sitio pequeño, aunque supongo que detrás de un balanceador tiene sentido. Es el siguiente cambio que voy a hacer, y después de recargar debería poder contarlos así:

sudo nginx -t && sudo systemctl reload nginx
sudo awk '$9==429{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head

Hay otra directiva que me habría venido bien, limit_req_dry_run (desde 1.17.1), que registra lo que se habría rechazado sin rechazarlo. La próxima vez que toque el límite la dejaré unos días en modo prueba antes de aplicarlo de verdad.

Lo que queda

Los pocos 502 que hay en los logs no son de las páginas estáticas. Casi todos vienen del panel de administración, que va detrás de nginx como proxy, y coinciden con los segundos en que reinicio su servicio. Hay uno del chat, por lo mismo. Se arreglaría con una página de mantenimiento y no tiene prisa. Lo que sí quiero mirar es systemd-analyze security nginx.service. nginx es lo único expuesto a Internet y para servir ficheros estáticos no necesita escribir en casi ningún sitio, así que un drop-in con ProtectSystem=full, PrivateTmp=yes y NoNewPrivileges=yes debería costar poco. Antes quiero ver qué nota le da ahora, para tener con qué comparar.