Bloquear scrapers de IA y bots ruidosos con nginx usando el User-Agent (primera línea de defensa)

Hace unos días, revisando el htop de un servidor en producción, me encontré con una carga sospechosamente alta para el tráfico real que se suponía que estaba recibiendo. Un vistazo rápido a los logs de nginx y ahí estaba el culpable: un desfile interminable de bots, scrapers de IA y crawlers de SEO machacando el sitio a todas horas. Nada de tráfico humano, solo robots haciendo de las suyas.

El servidor en cuestión hace de proxy inverso delante de varios sitios web, así que tiene una posición privilegiada: todo el tráfico pasa por él antes de llegar a los backends. Y eso lo convierte en el sitio ideal para poner una primera línea de defensa y filtrar la morralla antes de que toque nada más.

La idea es sencilla: muchos de estos bots se identifican (aún) honestamente con su User-Agent. GPTBot, ClaudeBot, Bytespider, AhrefsBot, SemrushBot y compañía van dejando su firma en cada petición. Así que, ¿por qué no usar precisamente eso como filtro y devolverles un escueto 403 en la cara?

Peculiaridad: esto va sobre Debian

Antes de entrar en materia, un apunte importante porque influye en dónde van los ficheros. Esto está montado sobre Debian, y en Debian (igual que en derivadas tipo Ubuntu) el nginx.conf que viene de serie ya incluye una línea como esta dentro del bloque http:

include /etc/nginx/conf.d/*.conf;

Eso significa que cualquier fichero .conf que dejemos en /etc/nginx/conf.d/ se carga automáticamente. Muy cómodo para tener la configuración bien troceada en lugar de meterlo todo en el mismo sitio. Si trabajas sobre otra distro o sobre FreeBSD, comprueba que ese include exista (o añádelo), porque si no, el fichero no se cargará por arte de magia.

El fichero del filtro

Creamos el fichero /etc/nginx/conf.d/bad-bots.conf con el siguiente contenido. Lo que hace es usar la directiva map para crear una variable $blocked_agent que valdrá 1 si el User-Agent coincide con alguno de los patrones, y 0 en caso contrario:

map $http_user_agent $blocked_agent {
    default 0;

    # --- Scrapers de IA / LLM ---
    "~*openai"                  1;   # OpenAI
    "~*GPTBot"                  1;   # OpenAI
    "~*ChatGPT-User"            1;   # OpenAI (plugins)
    "~*OAI-SearchBot"           1;   # OpenAI
    "~*ClaudeBot"               1;   # Anthropic
    "~*Claude-Web"              1;   # Anthropic
    "~*anthropic-ai"            1;   # Anthropic
    "~*Google-Extended"         1;   # Google AI (Bard/Gemini training)
    "~*Applebot-Extended"       1;   # Apple AI
    "~*Bytespider"              1;   # ByteDance / TikTok (MUY agresivo)
    "~*CCBot"                   1;   # Common Crawl
    "~*PerplexityBot"           1;   # Perplexity
    "~*Perplexity-User"         1;   # Perplexity
    "~*Amazonbot"               1;   # Amazon
    "~*FacebookBot"             1;   # Meta (entrenamiento IA)
    "~*meta-externalagent"      1;   # Meta external agent
    "~*meta-externalfetcher"    1;   # Meta external fetcher
    "~*Diffbot"                 1;
    "~*Omgilibot"               1;
    "~*Omgili"                  1;
    "~*Applebot"                1;
    "~*ImagesiftBot"            1;
    "~*cohere-ai"               1;
    "~*cohere-training-data-crawler" 1;
    "~*Timpibot"                1;
    "~*YouBot"                  1;
    "~*BacklinksExtendedBot"    1;
    "~*YandexBot"               1;
    "~*DataForSeoBot"           1;
    "~*facebookexternalhit"     1;

    # --- SEO / marketing crawlers ruidosos ---
    "~*AhrefsBot"               1;
    "~*SemrushBot"              1;
    "~*DotBot"                  1;   # Moz
    "~*MJ12bot"                 1;   # Majestic
    "~*BLEXBot"                 1;
    "~*MegaIndex"               1;
    "~*SeznamBot"               1;
    "~*serpstatbot"             1;
    "~*PetalBot"                1;   # Huawei
    "~*ZoominfoBot"             1;
    "~*Barkrowler"              1;
    "~*IbouBot"                 1;
    "~*AliyunSecBot"            1;
    "~*AwarioBot"               1;
    "~*Qwantbot"                1;

    # --- Herramientas de descarga / scraping genéricas ---
    "~*wget"                    1;
    "~*curl"                    1;   # ojo: bloquea curl legítimo, quítalo si lo usas
    "~*python-requests"         1;
    "~*python-urllib"           1;
    "~*Scrapy"                  1;
    "~*Go-http-client"          1;
    "~*libwww-perl"             1;
    "~*HTTrack"                 1;
    "~*WebCopier"               1;
    "~*WebReaper"               1;
    "~*Nutch"                   1;

    # --- Vacío o sospechoso ---
    ""                          1;   # User-Agent vacío
}

Un par de detalles sobre la sintaxis para que se entienda qué está pasando:

El ~* delante de cada patrón indica que es una expresión regular insensible a mayúsculas/minúsculas, así que GPTBot, gptbot o GPTBOT caerán igual. El default 0; es la clave: por defecto se deja pasar todo, y solo marcamos como bloqueado (1) lo que coincida explícitamente. Es un enfoque de lista negra, más permisivo que el contrario, pero cómodo de mantener.

La última entrada, "" 1;, bloquea las peticiones que llegan sin User-Agent. Un navegador o cliente legítimo casi siempre manda uno, así que un User-Agent vacío suele ser señal de algo automatizado y mal hecho.

Activar el filtro en cada vhost

La directiva map por sí sola no bloquea nada: solo define la variable. Para que entre en acción hay que usarla allí donde queramos. Dentro del server { ... } (o del location concreto) de cada vhost donde quieras aplicar el filtro, añadimos:

if ($blocked_agent) {
    return 403;
}

Y ya está. Si el User-Agent de la petición ha hecho saltar el map, nginx responde con un 403 Forbidden y ni se molesta en pasar la petición al backend. Lo bueno de tenerlo en el proxy inverso es que proteges de golpe todos los sitios que tengas detrás, sin tocar cada aplicación una por una.

Variante: return 444 (cerrar sin contestar)

El 403 es correcto y semánticamente honesto, pero tiene un pequeño coste: nginx genera y envía una respuesta de error al cliente. Si lo que quieres es gastar el mínimo de recursos posible con estos bots, nginx ofrece un código no estándar muy práctico, el 444, que cierra la conexión inmediatamente sin enviar ninguna respuesta:

if ($blocked_agent) {
    return 444;
}

El bot se queda con la conexión cortada en seco, sin cuerpo, sin cabeceras, sin nada. Consume menos ancho de banda y menos CPU que devolver una página de error, y de paso le das menos información a quien está al otro lado. La pega es que un 444 no deja rastro «bonito» de cara al cliente (que es justo lo que buscamos con un bot), pero conviene saber que, como cierra la conexión sin código HTTP visible, en los logs aparecerá precisamente como estado 444. Yo me quedo con esta variante para los bots claramente automatizados; el 403 lo reservo para casos donde quiero que conste explícitamente el «prohibido».

Antes de recargar, comprobamos siempre que la configuración es válida:

# nginx -t

Y si todo está correcto, recargamos sin cortar el servicio:

# systemctl reload nginx

Loguear los bloqueos para saber qué estás cazando

Bloquear está muy bien, pero a ciegas es incómodo: ¿cómo sabes si estás filtrando lo correcto, o si te estás cargando algo legítimo sin querer? La gracia es mandar los bloqueos a su propio log para poder auditarlos sin ensuciar el access_log general.

nginx permite condicionar la escritura de un access_log con if=, y aquí nos viene de perlas porque ya tenemos la variable $blocked_agent. Primero definimos un formato de log a nuestro gusto dentro del bloque http (por ejemplo, en el propio bad-bots.conf o en nginx.conf):

log_format blockedhosts '$remote_addr - [$time_local] '
                        '"$request" $status '
                        '"$http_user_agent" "$http_referer"';

Y luego, en el vhost, justo al lado del if, escribimos en ese log solo cuando la petición ha sido marcada como bloqueada:

if ($blocked_agent) {
    return 444;
}
access_log /var/log/nginx/blocked_agents.log blockedhosts if=$blocked_agent;

La clave está en el if=$blocked_agent del final: nginx solo escribirá una línea en blocked_agents.log cuando esa variable valga 1. El resto del tráfico legítimo no toca este fichero para nada. Así tienes un registro limpio y dedicado de todo lo que vas tumbando.

A partir de ahí, un simple vistazo te dice mucho. Por ejemplo, para ver qué User-Agents son los más insistentes:

# awk -F'"' '{print $2}' /var/log/nginx/blocked_agents.log | sort | uniq -c | sort -rn | head

Es una forma estupenda de ir afinando la lista: si ves caer algo que no deberías, lo quitas del map; y si descubres un bot nuevo dando guerra, lo añades. El log se convierte en tu fuente de verdad para iterar.

Truco: no te olvides de añadir blocked_agents.log a tu logrotate, o con el tiempo (y según lo agresivos que sean los bots) puede crecer más de lo que esperas.

Revisa y adapta la lista a tus necesidades

Esta lista es la que a mí me funciona, pero no la copies a ciegas. Repásala con calma y adáptala a tu caso:

Si usas curl para tus propios healthchecks, monitorización o despliegues, ese "~*curl" 1; te va a dar un buen susto bloqueando peticiones legítimas. Lo mismo con wget, python-requests o Go-http-client si tienes scripts internos que tiren del sitio. La lista es tuya: añade, quita y prueba.

La anécdota: -50% de carga

¿El resultado en aquel servidor de producción del principio? Tras aplicar el filtro, la carga del servidor se redujo en más de un 50%. Sí, más de la mitad de lo que estaba procesando era, lisa y llanamente, basura automatizada que no aportaba absolutamente nada. Da que pensar sobre cuánto del tráfico de internet hoy en día es simplemente robots hablando con robots.

Esto no es infalible (importante)

Llegados aquí toca el aviso de rigor, porque conviene tener expectativas realistas. Filtrar por User-Agent es una primera línea de defensa barata, rápida y sorprendentemente efectiva contra los bots que se identifican honestamente… pero nada más.

El User-Agent es una cabecera HTTP que el cliente envía y que se puede falsificar en un segundo. Cualquier scraper que tenga un mínimo de interés en saltarse el filtro solo tiene que mandar un User-Agent de un navegador normal y pasará tan tranquilo. Es decir: esto frena a los bots «educados» y a las herramientas genéricas, pero no detendrá a quien quiera entrar de verdad.

Para una protección seria contra tráfico malicioso, scraping agresivo o ataques, hay que subir un peldaño y plantearse un WAF (Web Application Firewall) o soluciones de mitigación más completas. Algunos ejemplos a tener en cuenta, según presupuesto y necesidades:

Cloudflare (con su modo de bloqueo de bots y de scrapers de IA), Anubis (un proof-of-work ligero pensado precisamente contra crawlers de IA y muy de moda últimamente), BitNinja o Sucuri, por citar solo unos pocos. Cada uno tiene su enfoque, sus ventajas y sus inconvenientes.

Pero mientras tanto, este filtro de nginx es un primer escalón estupendo: gratis, sin dependencias externas, fácil de mantener y que quita de en medio a una cantidad de ruido que asusta. Y a veces, recortar a la mitad la carga de un servidor con cuatro líneas de configuración es justo lo que necesitabas.

Deja un comentario

Este formulario guarda los datos que indiques de nombre, email y comentario para poder realizar un seguimiento de los comentarios dejados en cada entrada.