Defender WordPress de bots con Nginx

Un crawler de Alibaba Cloud recorrió decenas de URLs en pocos minutos y agotó los créditos de CPU de la instancia, dejándola capada al 5% de rendimiento. El blog estuvo inaccesible durante horas y, con la CPU throttleada, ni siquiera era posible acceder remotamente al servidor para intervenir. Solo quedaba esperar a que los créditos se recuperasen solos.

El incidente dejó claro que la configuración por defecto no era suficiente para una instancia pequeña expuesta a internet. La solución pasó por dos frentes complementarios: por un lado, configurar un límite de peticiones en Nginx para que cualquier cliente que supere un ritmo razonable empiece a
recibir rechazos antes de que pueda causar daño; por otro, ajustar las cabeceras de caché para que CloudFront sirva las páginas desde sus edge nodes y el EC2 apenas tenga que procesar peticiones repetidas. El resultado es que un crawler que recorra el blog hoy encontrará la mayoría de
páginas ya cacheadas en CloudFront, y las pocas que lleguen al servidor serán cortadas por el rate limiting antes de acumular carga.

Instancias T y créditos de CPU

Este blog personal corre en una instancia de familia T dentro con CloudFront delante tanto para el origen WordPress como para los assets de S3. La instancia es pequeña a propósito: el tráfico no justifica más, y los créditos de CPU del tipo T cubren perfectamente los picos puntuales de carga.

Los tipos T acumulan créditos de CPU cuando la instancia está por debajo de su línea base de uso, y los consumen cuando la superan. Mientras el consumo medio sea bajo, los créditos se van acumulando hasta un máximo. Cuando se agotan, la CPU queda capada al 5% y el servidor se vuelve prácticamente inutilizable.

Otra consecuencia es que el agente SSM no es operativo con CPU throttleada.

El incidente

El 7 de abril de 2026 el blog cayó dos veces.

Revisando la métrica CPUCreditBalance en CloudWatch, el patrón era llamativo: dos bajadas en picado separadas por unas horas, en las que los créditos pasaban de ~24 a 0 en menos de 35 minutos. No era degradación gradual, era consumo intensivo y repentino.

El ASG levantaba una instancia nueva automáticamente, que empezaba a acumular créditos desde cero. Pero a las pocas horas el ciclo se repetía.

La investigación

Lo primero fue ir a los logs de Nginx en CloudWatch. La instancia que había caído ya estaba terminada, pero el log stream se conserva. Filtrando el periodo justo antes de cada caída, el culpable apareció enseguida.

Una IP de Hetzner, 65.21.113.196, recorría el blog de forma sistemática: cada post, cada categoría, cada tag, a razón de una petición cada 2-3 segundos. Unos 20-30 requests por minuto sostenidos durante media hora, todos pasando por CloudFront y llegando al origen como páginas PHP completas. En una T, dependiendo de su tamaño, eso es suficiente para agotar los créditos rápidamente.

Cada página de WordPress implica ejecutar PHP, consultar MySQL, renderizar la plantilla. Un crawler que llega más rápido de lo que CloudFront puede poblar su caché fuerza al origen a procesar cada petición.

Todo esto unido a que la caché no está debidamente configurada, hacía que cualquier bot, crawler o intento de fuerza bruta tumbará el blog.

El problema con el rate limiting detrás de CloudFront

La solución obvia como primera defensa ante los ataques es el rate limiting en Nginx. Pero hay un problema: todo el tráfico llega a través de CloudFront, así que $remote_addr en Nginx siempre muestra una IP de los nodos edge de CloudFront (rangos 64.252.x.x3.172.x.x, etc.), no la IP real del cliente.

Si configuras limit_req_zone $binary_remote_addr, estás limitando por IP de CloudFront, no por IP de usuario. Podrías bloquear tráfico legítimo de miles de usuarios que comparten el mismo nodo edge.

La solución es decirle a Nginx que confíe en el header X-Forwarded-For cuando la petición provenga de una IP conocida de CloudFront. Para ello existe el módulo ngx_http_realip_module:

set_real_ip_from <rango_cloudfront>;
real_ip_header    X-Forwarded-For;
real_ip_recursive on;

Con esto, Nginx sustituye $remote_addr por el valor del header X-Forwarded-For, pero solo si la conexión viene de una IP en la lista set_real_ip_from. Si alguien conecta directamente a la EC2 e intenta falsificar el header, Nginx lo ignora porque la IP de conexión no es de confianza.

La lista de rangos origin-facing de CloudFront está disponible como AWS Managed Prefix List (pl-4fa04526 en eu-west-1):

aws ec2 get-managed-prefix-list-entries \
  --prefix-list-id pl-4fa04526 \
  --query "Entries[].Cidr" --output text

Son 45 rangos en total. Los añadí todos al nginx.conf.

Verificación

Tras el deploy, los logs de Nginx cambiaron de aspecto. 

Antes:

64.252.85.218 - - [07/Apr/2026:15:26:38...] "GET /..." 200 ... "Amazon CloudFront" "93.176.157.173"

Después:

93.176.157.173 - - [07/Apr/2026:17:20:03...] "GET /..." 200 ... "Amazon CloudFront" "93.176.157.173"

La configuración del rate limit en Nginx

En nginx.conf, dentro del bloque http (es donde se definen las zonas)

# CloudFront origin-facing IP ranges
set_real_ip_from 13.124.199.0/24;
set_real_ip_from 130.176.0.0/18;
# ... (45 rangos en total)
set_real_ip_from 64.252.64.0/18;
set_real_ip_from 70.132.0.0/18;
real_ip_header    X-Forwarded-For;
real_ip_recursive on;

# Rate limiting zones (keyed on real client IP after real_ip resolution)
    
# General: 10 req/min — 1 req/6s, suficiente para navegación normal
limit_req_zone $limit_req_key zone=general:10m rate=10r/m;

# wp-login: 5 req/min — brute force protection
limit_req_zone $binary_remote_addr zone=wplogin:10m rate=5r/m;
limit_req_status 429;

Luego aplicamos esas zonas en partes de nuestra aplicación:

# la zona que procesa las peticiones a nuestro wordpress
location ~ \.(php|phar)(/.*)?$ {
    limit_req zone=general burst=3 nodelay;
    fastcgi_split_path_info ^(.+\.(?:php|phar))(/.*)$;
    try_files $fastcgi_script_name =404;
    # ... fastcgi config
}

# wp-login.php: rate limit estricto anti fuerza bruta
location = /wp-login.php {
    limit_req zone=wplogin burst=2 nodelay;
    fastcgi_split_path_info ^(.+\.(?:php|phar))(/.*)$;
    # ... fastcgi config
}

La zona general limita a 10 requests por minuto con un burst de 3 peticiones inmediatas.
En la práctica: las tres primeras requests se sirven al instante; a partir de la cuarta, el cliente recibe un 429 si supera una request cada 6 segundos.

El crawler de Alibaba que tumbó el servidor hacía 20-30 requests por minuto — empieza a recibir 429s en la cuarta request, antes de que pueda agotar los CPU credits.

Los bots legítimos (Googlebot, Bingbot, Applebot) rastrean a un ritmo de 1 request cada varios minutos; no se acercan al límite.

La zona wp-login es más restrictiva: 5 requests por minuto con burst de 2, protección específica contra ataques de fuerza bruta al formulario de login.

Arreglando la caché

El problema de fondo no era solo el crawler: era que cada petición al blog, viniera de quien viniera, acababa llegando al EC2. CloudFront estaba delante, sí, pero sin instrucciones explícitas sobre qué podía cachear y durante cuánto tiempo. Funcionaba de forma implícita, aplicando un TTL por defecto que podía cambiar con cualquier modificación de infraestructura sin que nadie se diera cuenta.

La solución fue hacer ese comportamiento explícito desde Nginx, emitiendo una cabecera Cache-Control en todas las respuestas PHP. La clave estaba en distinguir dos tipos de visitante: un usuario anónimo navegando por el blog no tiene nada privado que ver, así que sus páginas pueden cachearse sin
problema; un usuario autenticado, en cambio, puede estar viendo el panel de administración o contenido de sesión, y eso no debe almacenarse en ningún intermediario.

Cache-Control condicional según sesión

Para hacer esa distinción, Nginx comprueba si la petición lleva la cookie de sesión de WordPress. Si la cookie está presente, la respuesta se marca como privada y CloudFront no la almacena. Si no está, la respuesta se marca como pública y CloudFront la cachea durante una hora en sus edge nodes.
El navegador, además, guarda su propia copia durante cinco minutos.

http {
  ....
  ....
  # Cache-Control condicional: usuarios logueados → private; anónimos → cacheable en CloudFront
  map $http_cookie $cache_control_header {
   ~wordpress_logged_in_  "private, no-store, no-cache";
   default                "public, max-age=300, s-maxage=3600";
  }
  ...
  ...
}

server {
  ...
  ...
  add_header Cache-Control $cache_control_header always;
}

El efecto práctico es que un crawler que recorra el blog hoy encontrará la inmensa mayoría de páginas ya servidas desde CloudFront, sin que el EC2 intervenga. Y los usuarios anónimos, que son el grueso del tráfico, reciben las páginas más rápido porque se sirven desde el edge más cercano a su ubicación.

Verificando que la caché funciona

Para verificar que todo funcionaba correctamente, la comprobación más básica es pedir las cabeceras de una página y ver qué responde el servidor:

curl -sI https://www.rubenortiz.es/
HTTP/2 200
content-type: text/html; charset=UTF-8
server: nginx
date: Sun, 12 Apr 2026 19:50:20 GMT
cache-control: public, max-age=3600, s-maxage=86400
link: <https://www.rubenortiz.es/wp-json/>; rel="https://api.w.org/"
x-cache: Hit from cloudfront
via: 1.1 60ee37c6b645cb12f58825f3fa5805ce.cloudfront.net (CloudFront)
x-amz-cf-pop: BCN50-P1
x-amz-cf-id: IRWIUaMDWadyGVwxkump_XJG3jNLX_tfruLbfu7aekVnUObj-8eFyg==
age: 24

La respuesta debe incluir Cache-Control: public, max-age=3600, s-maxage=86400. Si aparece esa cabecera, Nginx está emitiendo las instrucciones correctas y CloudFront sabe que puede cachear la página durante una hora.

El siguiente paso es confirmar que CloudFront efectivamente cachea. Haciendo dos peticiones seguidas a la misma URL, la primera responde con X-Cache: Miss from cloudfront — CloudFront no tenía la página y fue a buscarla al EC2. La segunda ya responde con X-Cache: Hit from cloudfront — esta vez la sirvió directamente desde el edge, sin tocar el servidor.

curl -sI https://www.rubenortiz.es/algun-post/ | grep -i x-cache
x-cache: Miss from cloudfront

curl -sI https://www.rubenortiz.es/algun-post/ | grep -i x-cache
x-cache: Hit from cloudfront

Para verificar que los usuarios autenticados quedan fuera de la caché, basta con simular la cookie de sesión de WordPress:

curl -sI https://www.rubenortiz.es/ -H "Cookie: wordpress_logged_in_xxx=yyy" | grep -i cache-control
cache-control: private, no-store, no-cache

La respuesta cambia a Cache-Control: private, no-store, no-cache. CloudFront no almacenará nada de esa sesión.

Por último, una comprobación de seguridad: los escáneres de vulnerabilidades suelen intentar acceder a archivos PHP que no existen buscando webshells o instalaciones expuestas. Con la configuración de
Nginx actualizada, esas peticiones devuelven un 404 limpio sin que PHP-FPM llegue a procesar nada:

curl -sI curl -sI https://www.rubenortiz.es//smtp/phpinfo.php
HTTP/2 404
content-type: text/html
content-length: 146
server: nginx
date: Sun, 12 Apr 2026 19:53:02 GMT
cache-control: public, max-age=3600, s-maxage=86400
x-cache: Error from cloudfront
via: 1.1 69f54fd525eb29c918e6e1c0a7125022.cloudfront.net (CloudFront)
x-amz-cf-pop: BCN50-P1
x-amz-cf-id: taME4MpcUUTzdYHkiAiACUebu9ftI2ibPhV7NmyB8Bs2pW5Cfp6VMA==

Conclusión

El incidente del 7 de abril fue provocado por un crawler de Alibaba que, en menos de 35 minutos, agotó todos los créditos de CPU de la instancia. El blog cayó dos veces en el mismo día. La causa de fondo era doble: la caché de CloudFront no tenía instrucciones explícitas y dejaba pasar cada petición al origen, y Nginx no tenía ningún mecanismo para frenar a un cliente que llegara demasiado rápido.

Las dos correcciones son complementarias y se refuerzan mutuamente. La caché reduce drásticamente el número de peticiones que llegan al EC2: si una página ya está en el edge de CloudFront, el servidor no hace nada. El rate limiting actúa como segunda línea: las peticiones que sí llegan al origen quedan limitadas a un ritmo razonable, y cualquier cliente que lo supere empieza a recibir 429s antes de poder causar daño.

Ninguna de las dos medidas por sí sola es suficiente. Solo caché significa que la primera pasada de un crawler, cuando CloudFront aún no tiene nada cacheado, puede seguir tumbando el servidor. Solo rate limiting sin caché significa que el EC2 sigue procesando todas las peticiones de usuarios legítimos, consumiendo créditos innecesariamente.

Juntas, dejan la instancia en una situación razonable para un blog personal: el tráfico normal se sirve desde el edge sin tocar el servidor, y cualquier comportamiento anómalo queda cortado en Nginx antes de que se acumule carga.

Dicho esto, hay un límite claro a lo que esta arquitectura puede aguantar. Una instancia T con CloudFront delante es una solución válida para un blog con tráfico bajo y predecible. Frente a un ataque volumétrico coordinado o un DDoS real, ni el rate limiting de Nginx ni los créditos de CPU son suficientes: para eso están AWS Shield y WAF, que quedan fuera del alcance de este post.

Links

¿Te ha ayudado este artículo?

Invítame a un café

Leave a Reply

Your email address will not be published. Required fields are marked *