Por qué el spam venció a mi formulario de contacto con límite de tasa

¿Por qué el spam atraviesa un formulario de contacto con límite de tasa? Porque un límite de tasa cuenta solicitudes por dirección y nada en él pregunta quién las envió. El 30 de octubre y el 2 de noviembre, dos escáneres de vulnerabilidades rastrearon mi sitio, cargaron la página de inicio que lleva el formulario, como lo hace cualquier visitante, obtuvieron un token CSRF válido y enviaron el formulario hasta que siguieron su camino o fueron bloqueados. El primer día pasaron 101 envíos y el segundo 31, cada uno un 200 de la aplicación, que lo devuelve tanto para un envío como para uno fallido, así que esas cifras son el máximo de correos que pudieron producir.12 El limitador de mi aplicación, configurado en cinco envíos por minuto por dirección, rechazó 830 y 31 de sus envíos y dejó pasar 101 y 31, 47 y 9 más de los que habría permitido un limitador exacto con esa configuración.3 El límite de tasa del borde nunca se activó con ninguno de los dos escáneres: lo creé unos 12 minutos después del último envío del formulario de la primera avalancha, y los envíos del segundo escáner llegaron por dos centros de datos de Cloudflare, que llevan contadores separados. Esa división es mi explicación más sólida para el segundo silencio: encaja con los totales por minuto, que no pueden probarla.456 En el sitio de mi estudio, un campo honeypot atrapó 42 de 52 envíos en ocho días y dejó pasar los 10 cuyos remitentes dejaron el honeypot vacío.78 El borde de este sitio sí tenía una regla capaz de preguntar si hay una persona del otro lado, un desafío gestionado en la ruta del formulario, pero solo se activa ante una cadena de agente de usuario incluida en una lista, y ambos escáneres enviaron la cadena de un navegador; un segundo desafío, basado en una puntuación de amenaza alta, nunca podía activarse, porque Cloudflare ahora fija esa puntuación en cero, así que nada se lo preguntó a cada envío.910411 Desde la mañana del 3 de noviembre, Turnstile se lo pregunta a cada envío que podría llegar a mi bandeja de entrada, en ambos formularios.12

TL;DR

  • Dos escáneres, una bandeja de entrada. Una dirección envió a mi sitio 76.429 solicitudes repartidas en 53 intervalos de un minuto el 30 de octubre; otra envió 10.907 en 22 el 2 de noviembre. Ambas rastrearon el sitio completo, ambas enviaron cadenas de inyección SQL en las rutas y en la cabecera User-Agent, y ambas enviaron el formulario de contacto: 1.065 veces el primer día y 500 el segundo.131412
  • Un token CSRF válido solo prueba que un cliente tenía un token emitido por mi sitio. Todos los envíos de ambos días llevaban uno, porque mis tokens no tienen estado y cualquier cliente que descarga la página obtiene uno. La verificación CSRF rechazó cuatro envíos en dos avalanchas.1516
  • Mi propio limitador tenía fugas. Lee un contador de Cloudflare KV, lo compara y luego lo vuelve a leer y escribe el conteo más uno en una solicitud aparte; si esa segunda lectura falla, escribe uno, y si falla una llamada a KV, recurre a un conteo guardado en la memoria de un solo proceso. Cualquiera de esas rutas puede dejar pasar más de cinco, y los logs no pueden decir cuál lo hizo: salieron 18 envíos en un minuto contra un límite de cinco.171
  • El límite de tasa del borde cuenta por dirección y por centro de datos. El 2 de noviembre, los 85 envíos del formulario del escáner entre las 18:56 y las 19:05:00 UTC, los minutos a ambos lados del bloqueo, llegaron por Ámsterdam y Londres, 44 y 41, y el minuto de más actividad se repartió 18 a 14 entre ambos. Esa división es la razón más probable por la que la regla guardó silencio, aunque los totales por minuto no pueden probarlo. Una prueba que hice a la mañana siguiente demostró que la regla se activa y que Cloudflare lo registra.518
  • Los bloqueos fueron bloqueos de dirección, y ambos llegaron demasiado tarde para mantener fuera el correo. El 30 de octubre bloqueé la dirección a mano 3,9 minutos después de darme cuenta, unos 50 minutos después de su primer envío del formulario. El 2 de noviembre mi monitor la bloqueó 8 minutos después del primer minuto del escaneo. Ningún envío de ninguno de los dos días llegó después del minuto del bloqueo: el 30 de octubre el último llegó más de 12 minutos antes del bloqueo, y el 2 de noviembre, 26 llegaron en los minutos previos y 5 en el propio minuto del bloqueo, que los datos por minuto no pueden situar antes o después del segundo del bloqueo.101912
  • El honeypot atrapa a los bots que llenan todos los campos. En el sitio de mi estudio respondió a 42 de 52 envíos como si hubieran tenido éxito y no guardó nada; según la hora en que llegaron, todos los envíos desde una salida de Tor estaban entre ellos. Los 10 que se guardaron, y por tanto como máximo 10 correos, vinieron de remitentes que dejaron el honeypot vacío.720
  • Qué cambió. Ambos formularios llevan ahora un widget de Cloudflare Turnstile en modo gestionado, y el servidor no guarda ni envía nada hasta que la API siteverify de Cloudflare acepta el token del envío, y rechaza uno ausente, demasiado largo o imposible de verificar. Turnstile ejecuta desafíos en el navegador del visitante y procesa señales como la dirección IP, la huella TLS y el agente de usuario; Cloudflare dice que no toca lo que se escribe en los formularios. No detiene a una persona que escribe spam a mano y no reemplaza los límites de tasa, que siguen acotando el volumen.122122

1. La pregunta: «¿eres tú?»

El 2 de noviembre mi bandeja de entrada se llenó de mensajes del formulario de contacto, y mi primera pregunta, dirigida a la sesión de agente que opera mi infraestructura, fue si mi propia automatización los estaba enviando. No era así. Los enviaba un escáner, a través del formulario de contacto de este sitio, y el formulario del sitio de mi estudio, 941apps.com, recibía su propio goteo lento, aunque nada de él llegó a la bandeja ese día.238 Los tres flujos de correo, las alertas de mi monitor y los dos formularios, salen de una misma dirección de mi propio dominio de correo, así que en la bandeja se veían iguales.23

Los asuntos delataron lo que pasaba. El controlador de contacto de este sitio me envía cada mensaje con el asunto «Contact Form:» seguido de lo que el remitente escribió en los campos de nombre, así que la carga de un escáner se convierte en una línea de asunto.2423 El formulario del sitio del estudio guarda cada mensaje en una base de datos y luego lo envía por correo a la misma bandeja.25

La primera pregunta que hice era la equivocada, y la correcta me llevó un día de consultas: ¿cuál de los controles de estos formularios podría haber distinguido un escáner de una persona, y por qué cada uno dejó pasar el resto? Había cinco tipos de control. En este sitio, un firewall en el borde con reglas personalizadas, un límite de tasa en el borde, un token CSRF y un limitador de tasa en la aplicación. En el sitio del estudio, el mismo tipo de reglas en el borde, un límite de tasa en el borde y un limitador propio de la aplicación que no rechazó ninguno de los envíos observados, y un campo honeypot.9257 El resto de este post recorre las dos avalanchas y el goteo lento uno por uno, luego las cinco capas una por una, con el texto de la regla o el comportamiento del código para cada una.

De dónde salen las cifras

Cada conteo que sigue proviene de los análisis de solicitudes y los eventos de firewall de Cloudflare para las dos zonas, consultados el 3 de noviembre sobre ventanas ya cerradas, más la tabla de mensajes del sitio del estudio y el log de mi propio monitor.16 Un script convirtió los resultados guardados de esas consultas en un registro de unos cuantos cientos de filas, cada una con la regla que la produjo, y el post toma sus cifras del registro y de su anexo y de ningún otro lugar. Donde mis notas del día no coinciden con el registro, gana el registro: escribí las notas del 30 de octubre minutos después de la avalancha, mientras Cloudflare todavía ingería sus últimos minutos, y se quedan cortas.1626 Cloudflare conserva estos conjuntos de datos «at least 31 days of data on every plan» (al menos 31 días de datos en todos los planes), así que pude contar una avalancha a posteriori, pero no para siempre.27

Dos tipos de cifra requieren una advertencia. Los eventos de firewall son una muestra, así que los uso para saber qué regla actuó y cuándo, y los conjuntos de datos de solicitudes para saber cuántas.16 Y un envío del formulario que recibió un 200 en este sitio es un envío, o un fallo al que el controlador también responde con 200 (tiene dos rutas de fallo así), no un correo entregado, y no revisé el log de mi proveedor de correo. Cada conteo de correos de este post es, por tanto, un techo.2428

Cómo se veía lo normal

Antes de las avalanchas, el formulario estaba tranquilo. En los ocho días que cubren las consultas, tres direcciones distintas de los dos escáneres enviaron el formulario con éxito, cada una exactamente dos veces, una dirección por día el 27, el 28 y el 29 de octubre.29 No sé si eran personas. Dos envíos desde una misma dirección encajan con un doble clic, y encajan con un bot que reintenta. Esa incertidumbre es todo el problema en miniatura: nada en los logs ni en los controles podía decirlo.

2. Primera avalancha: 30 de octubre, bloqueada a mano

La primera avalancha vino de una sola dirección en una red que el registro regional de internet lista bajo BULLIONET FBW NETWORKS SAS, registrada en Francia.30 Su primera solicitud cayó en el minuto de las 17:59 UTC, las 10:59 en horario de verano del Pacífico, y la última en el minuto de las 18:51, las 11:51 PDT: 53 intervalos de un minuto y 76.429 solicitudes a este sitio. Dos consultas independientes, una por minuto para la dirección y otra que barre todas las zonas de mi cuenta, coinciden en ese total, y la dirección no envió nada antes ni después.13

La forma del escaneo

El formulario fue una parte pequeña. Antes del bloqueo, la dirección promedió 1.389,7 solicitudes por minuto, con un pico de 3.219 en el minuto de las 18:36, y recorrió el sitio completo: al menos 2.881 rutas distintas, un límite inferior porque la consulta de rutas llegó a su límite de filas.1331 En todo el escaneo, los resultados se repartieron así:13

Resultado Solicitudes Proporción
Servidas (2xx, 3xx) 49.513 64,8 %
Rechazadas por la aplicación (4xx), incluidas 10.019 de la barrera de agentes del sitio 18.990 24,8 %
Errores de origen (520) 2.339 3,1 %
Bloqueadas en el borde (403) 5.587 7,3 %

El 7,3 % del borde es casi todo el bloqueo: 5.234 de sus 5.587 rechazos llegaron después del minuto del bloqueo.13 Durante los cincuenta minutos anteriores, solo actuó la regla de rutas de exploits. Esa regla funcionó desde el primer minuto, con el primer evento de firewall a las 17:59:53 UTC, pero coincide con subcadenas de ruta como .php y /wp-, y la mayor parte de este escaneo pedía páginas reales.109

Lo que lo convertía en un escaneo y no en un rastreo era lo que ponía en las solicitudes. Casi todo el tráfico, al menos 69.322 solicitudes, usaba un único agente de usuario de Chrome 141 para Windows, sin nada raro. Al menos 2.755 más decían ser Googlebot, y la regla de bots verificados del borde no las trató como Google.32 Al menos 3.154 solicitudes llevaban una carga de inyección SQL en la propia cabecera User-Agent, en al menos 396 cadenas distintas, y al menos 2.680 solicitudes llevaban una en la ruta, en al menos 810 rutas distintas.3231 Las cargas pertenecen a familias documentadas. PG_SLEEP(15), waitfor delay '0:0:15' y XOR(if(now()=sysdate(),sleep(15),0)) son sondas basadas en tiempo: la hoja de referencia de PortSwigger lista la sintaxis de retardo para Oracle, Microsoft SQL Server, PostgreSQL y MySQL, y las definiciones de cargas del propio sqlmap contienen las mismas funciones.3334 PortSwigger explica por qué funcionan: «As SQL queries are normally processed synchronously by the application, delaying the execution of a SQL query also delays the HTTP response.» (como la aplicación suele procesar las consultas SQL de forma síncrona, retrasar la ejecución de una consulta también retrasa la respuesta HTTP).35 Una respuesta lenta prueba poco; un retardo de unos 15 segundos que vuelve cada vez que la carga lo pide, y no en las solicitudes de control sin ella, es evidencia de una posible inyección.

La aplicación de pintura de este sitio tiene un endpoint para compartir, y el escáner lo encontró. Envió 320 POST a la ruta de compartir, la única dirección que lo hizo en los ocho días que cubren las consultas, y el endpoint rechazó los 320 y no guardó nada. Alrededor de ellos envió POST a variantes de la ruta con la carga incrustada, como /paint/share'||DBMS_PIPE.RECEIVE_MESSAGE(CHR(98)||CHR(98)||CHR(98),15)||' y /paint/(select 198766*667891 from DUAL), a las que el sitio respondió con 404.31 La dirección también tocó otra zona de mi cuenta, con 387 GET a kiradex.app, todos respondidos con 200, y ninguna más.13 Mi lectura es que siguió enlaces de este sitio a ese; no he revisado los enlaces.

El catálogo de amenazas automatizadas de OWASP tiene un nombre para esta actividad: OAT-014, Vulnerability Scanning, «Crawl and fuzz application to identify weaknesses and possible vulnerabilities.» (rastrear la aplicación y someterla a fuzzing para identificar debilidades y posibles vulnerabilidades).36 El formulario de contacto era una entrada más para el fuzzing.

El formulario, minuto a minuto

La dirección envió el formulario de contacto 1.065 veces, y todos los envíos llegaron a la aplicación; el borde no detuvo ninguno.1 El primer envío y el primer correo enviado llegaron en el minuto de las 18:00 UTC, las 11:00 PDT. Aquí está cada minuto en que un envío del formulario llegó a la aplicación, según el estado que devolvió la aplicación:1

UTC PDT Enviados (200) Rechazados por CSRF (403) Campo faltante (422) Rechazados por el limitador (429)
18:00 11:00 8
18:01 11:01 12 133
18:02 11:02 9 148
18:03 11:03 4 4 200
18:04 11:04 9 89
18:05 11:05 16 164
18:06 11:06 18 85
18:07 11:07 7 11
18:08 11:08 2
18:09 11:09 4
18:10 11:10 9
18:11 11:11 1
18:12 11:12 1
18:35 11:35 1
18:36 11:36 1
18:37 11:37 129

Los totales: 101 enviados, 830 rechazados por el limitador de la aplicación, 130 rechazados porque faltaba un campo obligatorio y 4 rechazados por la verificación CSRF, los cuatro en el minuto de las 18:03.1 Los 429 eran de la aplicación. La regla de límite de tasa del borde todavía no existía, no hay ningún evento de firewall de límite de tasa para la dirección, y donde Cloudflare registró el estado del origen para estos envíos, el 429 vino del origen.1037 Los 422 vinieron de la validación de formularios de FastAPI, que se ejecuta antes del limitador, así que nunca gastaron un turno del limitador; comprobé el orden con una aplicación FastAPI de diez líneas, en la que un envío sin un campo obligatorio devolvió 422 sin llamar nunca al envoltorio.38 La ráfaga de 129 a las 18:37 parece el escáner haciendo fuzzing de los campos del formulario al omitirlos.

Lee la columna «Enviados» frente al limitador. El limitador permite cinco envíos por dirección por minuto de reloj, y en ocho de esos minutos salieron más de cinco, 18 de ellos a las 18:06.13 La sección 4 explica cómo.

El aviso y el bloqueo

Me di cuenta a las 11:46 PDT y anoté que algo golpeaba el formulario de contacto del sitio una y otra vez.26 Para entonces, el primer envío del formulario tenía unos 46 minutos, y la avalancha sobre el formulario había terminado a las 11:37 PDT.1 Añadí la dirección a mano a la lista de bloqueo a nivel de cuenta con la que ya coincide la primera regla personalizada de la zona, y el primer evento de esa regla llegó a las 18:49:53 UTC, las 11:49:53 PDT.10 El bloqueo llegó 3,9 minutos después de que me di cuenta, unos 50 minutos después del primer envío del formulario y 50,9 minutos después del primer minuto del escaneo.113 Los 101 envíos ya habían salido antes; el último envío del formulario que llegó a la aplicación llegó unos 12 minutos antes del bloqueo.1 Después del minuto del bloqueo, la dirección envió 5.234 solicitudes más, y el borde las rechazó todas.13

En el mismo segundo, las 18:49:53 UTC, creé la única regla de límite de tasa de la zona: POST de formulario a las rutas de contacto y de compartir de la aplicación de pintura, 3 cada 10 segundos por dirección, con bloqueo durante 10 segundos.9 A las 18:52:38 UTC la probé desde mi propia máquina. En mis notas de esa prueba, seis envíos con un segundo de separación, con un agente de usuario de navegador y sin un token CSRF válido, «returned 403 403 403 429 429 429 (the app’s CSRF rejection, then the edge’s rate limit)» (devolvieron 403 403 403 429 429 429: el rechazo CSRF de la aplicación y luego el límite de tasa del borde), y los envíos simples con curl nunca llegaron a la regla, porque una regla personalizada existente les sirvió un desafío gestionado.26 Mis notas dicen que los envíos no llevaban token, pero un envío sin ese campo provoca un 422 antes de que se ejecute el controlador, como mostró mi prueba del 3 de noviembre, así que los 403 indican un token presente y no válido.18 Los eventos de firewall coinciden: tres eventos de límite de tasa desde mi dirección de prueba, el primero en ese segundo, y 24 desafíos gestionados para mis envíos con curl.39 Nada de mi tráfico de prueba está en ninguno de los conteos anteriores.

Mi monitor no estaba funcionando ese día. Lo volví a activar el 31 de octubre, y su detector de tráfico llegó después de esta avalancha.19 El bloqueo del 30 de octubre lo hizo una persona leyendo la bandeja de entrada.

Gráfico de barras, una barra por minuto de las 10:59 a las 11:51, hora del Pacífico, del 30 de octubre de 2026, con todas las solicitudes que una dirección de escáner envió a blakecrosley.com (76.429 en total), divididas según quién respondió. Antes del bloqueo las barras promedian 1.390 solicitudes por minuto (pico de 3.219 a las 11:36 PDT), sobre todo páginas servidas, con rechazos de la aplicación y algunos errores de origen; la avalancha sobre el formulario de contacto (1.065 envíos, 101 de ellos respondidos con 200, un envío o un fallo al que el controlador también responde con 200, así que como máximo 101 correos) cae en los 38 minutos de las 11:00 a las 11:37 PDT. Una línea punteada marca cuándo el autor se dio cuenta, las 11:46 PDT, y una línea discontinua el bloqueo que añadió a mano, en vigor a las 11:49:53 PDT (18:49:53 UTC), 51 minutos después del primer minuto del escaneo. Todas las solicitudes de los intervalos de minuto siguientes se bloquearon en el borde, hasta el último minuto con una solicitud, las 11:51 PDT.

30 de octubre: 76.429 solicitudes de una sola dirección, por minuto; hasta que bloqueé la dirección a mano, el borde casi no rechazó ninguna.1310

3. Segunda avalancha: 2 de noviembre, bloqueada por mi monitor

La segunda dirección pertenece a NovoServe B.V., un proveedor de hosting en los Países Bajos.40 Envió dos GET a las 07:49 UTC, las 23:49 en horario estándar del Pacífico del 1 de noviembre, y luego nada durante 11,1 horas. El escaneo propiamente dicho fue del minuto de las 18:56 UTC al de las 19:17, de las 10:56 a las 11:17 PST: 22 intervalos de un minuto y 10.907 solicitudes, o 10.909 con los dos GET tempranos. Cuatro consultas independientes coinciden en el total.14

Parecía la misma herramienta. Usó la misma cadena de agente de usuario de Chrome 141 para Windows que la dirección del 30 de octubre en 9.725 solicitudes, la misma cadena de Googlebot en 942, las mismas familias de inyección, y tocó las mismas dos zonas y ninguna otra, con 368 GET a kiradex.app.414214 Dos direcciones en dos redes de dos países no prueban nada sobre quién las operaba, así que lo dejo en «parecía».

La cronología, en ambas zonas horarias

UTC PST Qué pasó
18:56 10:56 Empieza el escaneo: 29 solicitudes en el minuto.1419
18:57:00 10:57:00 Arranca una ejecución de mi monitor. Su ventana cierra a las 18:57:22, así que ve alrededor de un minuto del escaneo, y no encuentra nada.19
18:57 10:57 El minuto de más actividad antes del bloqueo, 637 solicitudes. La regla de rutas de exploits del borde empieza a bloquear a las 18:57:11.144
18:58 10:58 Los tres primeros envíos del formulario. La aplicación envía los tres.2
19:00 11:00 Cuatro envíos más del formulario, los cuatro enviados.2
19:02 11:02 32 envíos del formulario llegan a la aplicación: 9 enviados, 23 rechazados por el limitador de la aplicación.2
19:02:46 11:02:46 Arranca la siguiente ejecución del monitor.19
19:03 11:03 10 enviados, 8 rechazados.2
19:03:53 11:03:53 Mi monitor levanta un hallazgo crítico de avalancha de solicitudes: 1.914 solicitudes de la dirección.19
19:04:01 11:04:01 Mi monitor añade la dirección a la lista de bloqueo de la cuenta.19
19:04:03 11:04:03 El primer evento de la regla de la lista de bloqueo.4
19:04 11:04 Los últimos cinco envíos y 22 envíos del formulario rechazados en el borde, todos en el propio minuto del bloqueo; el intervalo del minuto no dice qué llegó primero.2
19:07 11:07 El minuto de más actividad del escaneo, 1.107 solicitudes, todas bloqueadas.14
19:17 11:17 El último minuto, 300 solicitudes, todas bloqueadas.14

Los ocho minutos de mi monitor

Mi monitor levantó su primer hallazgo 7,9 minutos después del primer minuto del escaneo, y el bloqueo entró en vigor 8,0 minutos después de ese minuto, o 6,8 minutos después del primer evento de firewall.194 Para entonces habían salido 26 envíos en los minutos previos al bloqueo, y los últimos 5 cayeron en el propio minuto del bloqueo, que los datos por minuto no pueden situar antes o después del segundo del bloqueo; ninguno llegó en los minutos siguientes.2 Ocho minutos es rápido para un bloqueo y lento para una bandeja de entrada.

La ejecución que arrancó a las 10:57:00 PST tuvo el escaneo a la vista durante alrededor de un minuto, y en su ventana la dirección hizo entre 29 y 666 solicitudes; la cifra superior cuenta el minuto completo de las 18:57, del que la ventana solo vio 22 segundos. El detector no registró ningún hallazgo.19 La siguiente ejecución, a las 11:02:46 PST, encontró la avalancha y la bloqueó, y esa ejecución muestra por qué la primera pudo pasarla por alto. Su ventana terminaba en el momento en que consultaba a Cloudflare, antes de que llegaran los eventos más recientes: su log registra 1.914 solicitudes de la dirección, mientras que mi repetición de la misma ventana de cinco minutos el 3 de noviembre, sobre datos completos, cuenta 2.143; la repetición está guardada con sus comandos y su salida completa, y sus conteos son filas del anexo del registro.1943 La repetición también levanta un hallazgo de avalancha sobre el formulario que la ejecución real nunca levantó, a partir de 39 envíos, 23 de ellos rechazados, con severidad alta, que habría sido una línea en el resumen diario y no un correo ni un bloqueo.43 Repetí la ventana de la segunda ejecución, no la de la primera, así que para la ejecución de las 10:57 PST el mismo retraso es la causa probable, no una medida. Ese mismo día cambié las ventanas del monitor para que terminen 90 segundos antes de cada ejecución y empiecen donde terminó la anterior.43 En total se dispararon seis hallazgos sobre la dirección, y mi monitor los incorporó todos a su resumen diario en lugar de enviarme un correo, porque la dirección ya estaba en su registro de bloqueos.19 Cómo debería alertar mi monitor, y este escáner visto desde su lado, es el tema de un post aparte; para este, lo que importa es que un bloqueo es una reacción, y la reacción llegó después de al menos 26 de los 31 envíos.

El formulario, por estado

La dirección envió el formulario 500 veces.2

UTC PST Enviados (200) Rechazados por el limitador (429) Bloqueados en el borde (403)
18:58 10:58 3
19:00 11:00 4
19:02 11:02 9 23
19:03 11:03 10 8
19:04 11:04 5 22
19:05 11:05 39
19:06 11:06 19
19:07 11:07 10
19:08 11:08 13
19:09 11:09 31
19:10 11:10 41
19:11 11:11 47
19:12 11:12 97
19:13 11:13 29
19:14 11:14 23
19:15 11:15 21
19:16 11:16 28
19:17 11:17 18

Los totales: 31 enviados, 31 rechazados por el limitador de la aplicación y 438 rechazados en el borde, todos en el minuto del bloqueo o después. Así que 62 envíos llegaron a la aplicación.2 Cada envío exitoso prueba que el escáner tenía un token CSRF válido, porque el controlador verifica el token antes de enviar. Los 429 no prueban nada en ningún sentido, porque el limitador rechaza un envío antes de que el controlador lea su token.2416 Donde Cloudflare registró el estado del origen para esos 429, era el del origen, el del limitador de la aplicación, y no hay ningún evento de firewall de límite de tasa para la dirección.374

El resto del escaneo

Por resultado, el escaneo del 2 de noviembre se repartió así:14

Resultado Solicitudes Proporción
Servidas (2xx, 3xx) 1.388 12,7 %
Rechazadas por la aplicación (4xx), incluidas 420 de la barrera de agentes 1.473 13,5 %
Errores de origen (520) 116 1,1 %
Bloqueadas en el borde (403) 7.930 72,7 %

El 72,7 % del borde es sobre todo el bloqueo: 2.986 solicitudes llegaron antes del minuto del bloqueo y 7.445 después, y el sitio no sirvió ninguna de estas últimas.14 La consulta de rutas de este día fue completa: 2.173 rutas distintas, y 778 solicitudes en 542 de ellas llevaban una carga de inyección SQL.44 La consulta de agentes de usuario también fue completa, 73 cadenas distintas. Además de la cadena simple de Chrome y de la que decía ser Googlebot, el escáner envió 54 cargas SQL distintas en 149 solicitudes, tres cadenas para romper comillas en 78 y 12 tokens marcadores, cada uno enviado una vez.41

Vale la pena mirar algunas de esas cadenas, porque muestran lo que el escáner preguntaba. (select 198766*667891 from DUAL) es una expresión aritmética dentro de un select sin tabla; FROM dual es la sintaxis de Oracle para eso, como muestra la hoja de referencia de PortSwigger.33 if(now()=sysdate(),sleep(15),0) tiene la misma forma IF(condition, SLEEP(n), x) que el retardo condicional de la misma hoja de referencia.33 @@qkLpP es un prefijo @@, el que usa la hoja de referencia en SELECT @@version para Microsoft y MySQL, seguido de cinco caracteres, y el escáner envió una docena parecidas.3341 La guía de pruebas de OWASP nombra las dos técnicas a las que sirven estas cadenas: la inyección basada en errores, que «forces the database to generate an error» (obliga a la base de datos a generar un error), y los retardos de tiempo que «use database commands (e.g. sleep) to delay answers in conditional queries.» (usan comandos de la base de datos, como sleep, para retrasar las respuestas en consultas condicionales).45 Ninguna de las páginas que consulté documenta estas cadenas exactas, así que las describo y no las atribuyo a una herramienta concreta.

Gráfico de barras, una barra por minuto de las 10:56 a las 11:17, hora del Pacífico, del 2 de noviembre de 2026, con todas las solicitudes que una dirección de escáner envió a blakecrosley.com (10.907 en total), divididas según quién respondió. Antes del bloqueo las barras son sobre todo páginas servidas y rechazos de la aplicación, con un pico de 637 solicitudes en un minuto; 62 de los envíos del formulario de contacto de la dirección llegaron a la aplicación, y 31 de ellos se respondieron con 200, un envío o un fallo al que el controlador también responde con 200, así que como máximo 31 correos: 26 en los minutos previos al minuto del bloqueo y 5 en el propio minuto del bloqueo, ninguno en los minutos siguientes. Una línea discontinua marca el bloqueo a las 11:04:01 PST (19:04:01 UTC), 8 minutos después del primer minuto del escaneo, colocado por el monitor; finas líneas punteadas marcan la ejecución del monitor de las 10:57:00 PST, que solo vio los primeros momentos del escaneo y no encontró nada, y la ejecución de las 11:02:46 PST, que levantó el hallazgo de avalancha. Todas las solicitudes de los intervalos de minuto siguientes (7.445 en total) se bloquearon en el borde, con un pico de 1.107 en un minuto.

2 de noviembre: 10.907 solicitudes de una sola dirección, por minuto, con las dos ejecuciones de mi monitor y el bloqueo que colocó a las 11:04:01 PST.1419

4. Las cinco capas, y lo que cada una podía distinguir

Cinco tipos de control se interponían entre estos bots y mi bandeja de entrada. A cada uno le hice la misma pregunta: ¿podía distinguir un bot de una persona? Un desafío gestionado en las reglas del borde puede hacerle esa pregunta a un visitante, pero solo a las solicitudes cuya cadena de agente de usuario está en su lista, y las de los escáneres no lo estaban; un segundo, basado en una puntuación de amenaza, nunca podía activarse, así que nada se lo preguntó a cada envío. Dos de los controles cuentan, uno compara nombres y listas, uno prueba que un cliente tiene un token emitido por el sitio y uno atrapa solo a los bots que llenan un campo oculto. Aquí está cada uno, qué verifica, qué detuvo y la razón exacta por la que dejó pasar el resto.

Reglas personalizadas del borde

Qué verifican. En este sitio, la primera regla personalizada bloquea cualquier dirección que esté en una lista a nivel de cuenta, además de siete redes de hosting por ASN. Otra bloquea subcadenas de rutas de exploits como .php, /wp- y /.env. Otras dos sirven un desafío gestionado en el formulario de contacto y en la ruta de administración: una para un agente de usuario vacío o que contenga curl, wget, python-requests, httpx, scrapy, HeadlessChrome, PhantomJS o Selenium, y otra para una puntuación de amenaza alta de Cloudflare. El sitio del estudio tiene reglas del mismo tipo, con ambos desafíos aplicados a todo el sitio y un bloqueo en las rutas de la API que exime al formulario de contacto.9 Los desafíos por puntuación de amenaza, uno en cada sitio, eran reglas muertas: la referencia de campos de Cloudflare dice que la puntuación iba antes de 0 a 100, y «Now, the threat score is always 0 (zero).» (ahora la puntuación de amenaza es siempre 0). Una regla que pide una puntuación alta no coincide con nada, y desactivé ambas el 3 de noviembre, el día en que lo leí; los desafíos por agente de usuario siguen.1146 El plan Free de Cloudflare permite cinco reglas personalizadas, que «work as customized web application firewall (WAF) rules that you can use to perform actions like Block or Managed Challenge on incoming requests.» (funcionan como reglas de firewall de aplicaciones web personalizadas con las que puedes realizar acciones como Block o Managed Challenge sobre las solicitudes entrantes).47

Qué detuvieron. Las rutas de exploits, desde el primer minuto en ambos días.104 El 30 de octubre, ningún envío del formulario.110 El 2 de noviembre, los 438 envíos del formulario posteriores al bloqueo de mi monitor.2 En el sitio del estudio, ningún envío del formulario: el firewall no registró ningún evento sobre un POST al formulario en los ocho días.7

Por qué pasó el resto. Ninguna regla mira qué es una solicitud. La regla de la lista solo coincide con una dirección después de que algo la pone en la lista, una persona el 30 de octubre y mi monitor el 2 de noviembre.91019 El desafío por agente de usuario compara una cadena, y ambos escáneres enviaron una cadena de navegador, al menos 69.322 veces el primer día y 9.725 veces el segundo.3241 El desafío por agente de usuario sí funciona contra las herramientas que se anuncian: mi propia prueba con curl del 30 de octubre provocó 24 desafíos gestionados.39 El desafío por puntuación de amenaza no podía activarse con nadie, y el firewall no registró ningún desafío en el formulario para ninguna de las dos direcciones.11104 Las salidas de Tor y las redes de hosting fuera de los siete ASN nombrados no están en ninguna lista.

Una regla del borde puede distinguir una dirección conocida, una red conocida o una herramienta honesta del resto, y su desafío pregunta si hay una persona solo a las solicitudes que selecciona su expresión; no puede distinguir una cadena de navegador enviada por un escáner de un navegador.

Límite de tasa del borde

Qué verifica. El texto de la regla es corto:9

(http.request.method eq "POST" and http.request.uri.path in {"/api/contact" "/paint/share"})

Cuenta las solicitudes que coinciden por centro de datos de Cloudflare y dirección del cliente, permite 3 cada 10 segundos y bloquea durante 10 segundos. Repartido de forma uniforme, ese techo equivale nominalmente a 18 por minuto por dirección y por centro de datos; la propia regla cuenta en periodos de 10 segundos.916 El plan Free fija el periodo, el tiempo de espera y la clave de conteo, y da a la zona una sola regla de este tipo: un periodo de conteo de 10 segundos, un tiempo de mitigación de 10 segundos y conteo por IP.48 El centro de datos tampoco es opcional. «Every rate limiting rule includes the Cloudflare data center ID (cf.colo.id) as a mandatory characteristic» (toda regla de límite de tasa incluye el ID del centro de datos de Cloudflare como característica obligatoria), dice la documentación de Cloudflare, y «Counters are not shared across data centers, with the exception of data centers associated with the same geographical location.» (los contadores no se comparten entre centros de datos, salvo los asociados a la misma ubicación geográfica).6

Qué detuvo. Ninguna solicitud de los escáneres en ninguno de los dos días. Sus únicos eventos en la ventana del incidente fueron los tres de mi propia prueba.10439 El sitio del estudio tiene su propia regla de límite de tasa para todo el sitio, que cuenta del mismo modo todas las solicitudes salvo las de clientes que Cloudflare lista como bots verificados, y no rechazó ninguno de los envíos del goteo: su firewall no registró ningún evento sobre un envío al formulario en los ocho días. Esa regla cuenta todas las solicitudes de ese tipo que una dirección envía al sitio, y mis datos solo cuentan los envíos del formulario, así que no miden cuánto margen le quedaba al contador.97

Por qué pasó el resto. El 30 de octubre, la regla no existió hasta las 18:49:53 UTC, el mismo segundo del primer evento del bloqueo manual y unos 12 minutos después de que el último envío del formulario llegara a la aplicación.91 Los 1.065 envíos de ese escáner llegaron todos por un solo centro de datos, París, así que una regla vigente habría llevado un solo contador para ellos.5 El 2 de noviembre la regla existía y los envíos del formulario del escáner llegaron por dos centros de datos, Ámsterdam con 44 y Londres con 41, entre las 18:56 y las 19:05:00 UTC. En el minuto de más actividad, las 19:02, Londres llevó 18 y Ámsterdam 14.5 Dieciocho en un minuto es el equivalente nominal del techo de la regla, pero mis datos contienen totales por minuto, no tiempos de llegada, así que no pueden mostrar si algún periodo de 10 segundos en un centro de datos tuvo más de tres. Cloudflare es claro sobre la precisión: «Rate limiting rules are not designed to allow a precise number of requests to reach your origin server. There may be a delay of up to a few seconds between detecting a request and updating rate counters.» (las reglas de límite de tasa no están diseñadas para dejar llegar un número preciso de solicitudes al servidor de origen; puede haber un retraso de hasta unos segundos entre detectar una solicitud y actualizar los contadores).48 En el lapso de siete minutos entre su primer envío que llegó a la aplicación y el último, el escáner promedió 8,86 por minuto.2 Mis notas del día lo llamaron «about nine a minute» (unos nueve por minuto) y se detuvieron ahí; la división entre centros de datos es lo que se les escapó.23

Quería una prueba de que la regla funciona, así que la mañana del 3 de noviembre la sondeé desde mi propia máquina. A las 06:36:49 PST envié diez POST con 0,4 segundos de separación sin token CSRF: cuatro volvieron con 422, porque faltaba un campo obligatorio y FastAPI los rechazó, y luego seis volvieron con 429. A las 06:38:03 PST envié ocho con todos los campos presentes: tres volvieron con 403, el rechazo CSRF del origen, y luego cinco volvieron con 429 con el cuerpo «error code: 1015» de Cloudflare. El firewall registró 11 eventos en el formulario en esos minutos, todos ellos bloqueos de la regla de límite de tasa.18 La regla se activa, y Cloudflare lo registra. Mi lectura del 2 de noviembre es una hipótesis: repartido entre dos contadores y cerca del techo nominal en cada uno, el escáner nunca hizo saltar ninguno. Es la explicación más sólida que encaja con los totales por minuto, no una demostración; los totales no pueden mostrar cómo se espaciaron los envíos dentro de cada minuto, y Cloudflare admite que pueden pasar solicitudes de más mientras se actualizan sus contadores.548

Un límite de tasa puede distinguir lo rápido de lo lento, para una dirección en un centro de datos; no puede distinguir quién envía.

Token CSRF

Qué verifica. El formulario de este sitio lleva un token oculto, insertado en la página, con tres partes: una marca de tiempo, un valor aleatorio y una firma HMAC sobre los dos primeros. El servidor lo acepta si la firma coincide y la marca de tiempo tiene menos de una hora.15 El formulario del sitio del estudio no tiene token CSRF.25

Qué detuvo. Cuatro envíos el 30 de octubre, todos en el minuto de las 18:03, y ninguno el 2 de noviembre.12

Por qué pasó el resto. El token no tiene estado. No está ligado a una sesión, a una cookie ni a una dirección, así que cualquier cliente que descarga la página obtiene uno válido, y cualquier cliente al que otro le pase un token puede presentarlo mientras dure. Ambos escáneres sí descargaron las páginas del sitio; eso es lo que muestran los logs de solicitudes. Lo que muestra la verificación es más acotado: cada envío de ambos días llevaba un token válido y no vencido emitido por mi sitio, porque el controlador verifica el token antes de enviar.1513142416 Un token CSRF existe para impedir que otro sitio haga que el navegador de un visitante envíe mi formulario. Nunca se pensó para detener a un cliente que quiere enviar el formulario por sí mismo, y no lo hizo.

La misma verificación tenía un segundo defecto, y recaía en las personas y no en los bots. Cuando el token fallaba, el controlador lanzaba un 403 en JSON que el formulario HTMX nunca mostraba, así que una persona con un token vencido habría hecho clic en Enviar y no habría visto nada.12 Lo arreglé, aunque mi primer arreglo tampoco funcionó; la sección 6 cuenta cómo.

Un token CSRF válido prueba que el cliente tenía un token no vencido emitido por mi sitio; no puede probar quién llenó el formulario, ni siquiera que este cliente cargó la página.

Limitador de tasa de la aplicación

Qué verifica. El controlador de este sitio está detrás de un decorador que cuenta los envíos del formulario por dirección de cliente y por minuto de reloj en Cloudflare KV, con un respaldo en memoria, y rechaza a partir de cinco con un 429.17 El sitio del estudio tiene un limitador en memoria por proceso para clientes anónimos, que cuenta por dirección y por clasificación del cliente, la clase que el limitador asigna a partir del agente de usuario y la dirección, sobre todas las rutas salvo las comprobaciones de salud y los archivos estáticos.25

Qué detuvo. 830 envíos el 30 de octubre y 31 el 2 de noviembre. Ninguno en el sitio del estudio.127

Por qué pasó el resto. Por diseño deja pasar cinco por minuto a cualquiera, y como la ventana es el minuto de reloj, una ráfaga que cruza el cambio de minuto recibe dos cupos.17 Eso ya lo sabía. Lo que no sabía hasta que leí los conteos frente al código es que tiene fugas, y el código tiene al menos tres formas de tenerlas. El limitador lee el contador de KV por HTTP, lo compara con el límite y luego escribe el conteo más uno en una solicitud aparte, así que dos envíos que leen antes de que cualquiera escriba pasan los dos. El incremento lee el contador una segunda vez antes de escribir, y si esa segunda lectura falla, la escritura devuelve el conteo a uno, así que el minuto vuelve a empezar. Y siempre que la lectura de KV no devuelve nada o la escritura falla, la decisión recurre a una lista guardada en la memoria de un solo proceso, que los demás procesos no comparten.17

Los conteos muestran la fuga. El 30 de octubre, ocho minutos tuvieron más de cinco envíos, y el minuto de las 18:06 tuvo 18.1 Un limitador exacto a cinco por minuto habría permitido como máximo 54 envíos en los minutos en que el escáner envió, y salieron 101, 47 por encima del techo. El 2 de noviembre, los minutos de las 19:02 y las 19:03 dejaron pasar 9 y 10; un limitador exacto habría permitido como máximo 22 de los 62 envíos que vio, y salieron 31, 9 por encima del techo.3 No muestran qué ruta tuvo la fuga. Mi lectura es que hubo envíos que leyeron el mismo conteo antes de que cualquiera escribiera, o que las llamadas a KV fallaron bajo la carga, reiniciando el conteo o entregando la decisión a las listas por proceso, o alguna combinación de esto; los logs que tengo registran el estado que devolvió la aplicación, no qué ruta lo decidió, así que no pueden distinguir las rutas.173 El remedio es el mismo para todas, y no es un cambio de una línea: un almacén de contadores con un incremento atómico, algo que una lectura de KV seguida de una escritura aparte no ofrece. La fuga es un bug mío. También hace que el argumento de este post sea más afilado de lo que habría podido hacerlo a propósito: un control que dice cinco y deja pasar dieciocho nunca iba a ser lo que decide quién puede escribirme.

En el sitio del estudio, el limitador de la aplicación no rechazó ninguno de los envíos observados, y la dirección más activa envió 13 en una hora. Sus contadores suman las solicitudes de una dirección a cualquier otra ruta bajo la misma clasificación de cliente, y mis datos solo cubren los envíos del formulario, así que los conteos no miden cuánto margen tenían esos contadores.725

Un limitador de tasa puede distinguir cinco de seis cuando las solicitudes se turnan; no puede distinguir una persona de un script con ningún conteo.

Honeypot

Qué verifica. El formulario del sitio del estudio tiene un campo de texto llamado website que una persona nunca ve: lleva tabindex="-1" y aria-hidden, así que ni el teclado ni un lector de pantalla llegan a él. Si vuelve con algo escrito, el controlador responde con el mismo mensaje de éxito que vería una persona, y no guarda ni envía nada.25 Esa rama está en el controlador desde que construí el formulario el 5 de junio.25 El formulario de este sitio no tiene honeypot.24

Ned Batchelder describió la técnica en 2007: «These fields are called honeypots and are validated when the form data is posted. If they contain any text, then the submitter must be a bot» (estos campos se llaman honeypots y se validan cuando se envían los datos del formulario; si contienen algún texto, quien envía debe ser un bot).49 Su razón de que funcione es que los bots que llenan formularios «cannot distinguish invisible fields from visible ones.» (no pueden distinguir los campos invisibles de los visibles).49

Qué detuvo. 42 de los 52 envíos de los ocho días, el 80,8 %, incluido cada envío desde una salida de Tor según la hora en que llegaron y, por la hora y el patrón del mensaje, los 13 de una ráfaga de un autodenominado SQL spider.720

Por qué pasó el resto. Un bot que solo llena los campos visibles nunca toca website, y los remitentes cuyos envíos se guardaron, y por tanto podían llegar a mi bandeja, lo dejaron vacío.825 Que eligieran los campos visibles a propósito es mi lectura; el controlador solo registra que el oculto volvió vacío. Batchelder lo vio venir: «In theory, it is possible for a spambot to defeat any of these measures. But in practice, bots are very stupid» (en teoría, un spambot puede vencer cualquiera de estas medidas; pero en la práctica, los bots son muy tontos).49 Algunos son menos tontos que otros, y los que venden generación de leads son los menos tontos.

Un honeypot solo prueba que el cliente llenó un campo que una persona no puede ver; uno vacío no prueba nada.

Gráfico de barras horizontales en tres grupos, uno por flujo, cada barra una proporción de los envíos que llegaron al borde. Escáner del 30 de octubre en blakecrosley.com: 1.065 llegaron al borde, los 1.065 llegaron a la aplicación, 101 se respondieron con 200, un envío o un fallo al que el controlador también responde con 200, así que como máximo 101 llegaron a la bandeja de entrada. Escáner del 2 de noviembre: 500 llegaron al borde, 62 llegaron a la aplicación, ninguno después del minuto del bloqueo, 31 se respondieron con 200 (26 en los minutos previos al minuto del bloqueo y 5 en el propio minuto del bloqueo), así que como máximo 31 llegaron a la bandeja. 941apps.com en ocho días: 52 llegaron al borde, los 52 llegaron a la aplicación, 10 se guardaron y por tanto como máximo 10 se enviaron por correo; el honeypot respondió a los otros 42 como si hubieran tenido éxito y no guardó nada.

Hasta dónde llegó cada flujo: los envíos de los dos escáneres en este sitio y ocho días del goteo en el sitio del estudio.1278

Lo que tenían en común las cinco

Lee juntas las cinco frases y el hueco salta a la vista. Los límites de tasa acotan el volumen por dirección. Un token CSRF prueba que un cliente tenía un token emitido por el sitio. Un honeypot prueba que el cliente llenó un campo oculto. Las reglas del borde conocen nombres y listas, y la que podría preguntar si hay una persona del otro lado, el desafío por agente de usuario, solo pregunta a las solicitudes cuya cadena de agente de usuario está en su lista; el desafío por puntuación de amenaza que tiene al lado nunca podía activarse. Ninguno de los dos escáneres coincidió, así que nada se lo preguntó a cada envío, y los escáneres no hicieron nada que un navegador no pueda hacer: descargaron la página, guardaron su token y enviaron a un ritmo que los límites dejaron pasar en parte. La guía de OWASP contra el relleno de credenciales hace la misma observación sobre otro formulario, y aconseja defensas que «not rely on a single predictable volume limit» (no dependan de un único límite de volumen predecible).50

5. El goteo lento en el sitio del estudio

El formulario del sitio del estudio nunca tuvo una avalancha. Tuvo un goteo, y el goteo es otra cosa. La ventana aquí va desde la primera hora con un envío del formulario en la consulta de ocho días, las 17:00 UTC del 26 de octubre, hasta el momento en que guardé los datos, las 13:59:50 UTC del 3 de noviembre; el primer y el último día son parciales.7

Envíos frente a filas guardadas

El formulario recibió 52 POST en esa ventana y respondió a todos con 200. La tabla de mensajes ganó 10 filas en la misma ventana. Como la rama del honeypot es la única ruta del controlador que responde 200 y no guarda nada, el honeypot atrapó los otros 42, el 80,8 %.7825 Por día UTC:78

Fecha (UTC) Envíos (200) Filas guardadas Atrapados por el honeypot
26 oct (parcial) 4 2 2
27 oct 5 0 5
28 oct 3 2 1
29 oct 5 1 4
30 oct 16 1 15
31 oct 8 2 6
1 nov 4 0 4
2 nov 5 0 5
3 nov (parcial) 2 2 0

El pico del 30 de octubre es una dirección en Gran Bretaña que envió 13 veces en una sola hora con el agente de usuario Mozilla/5.0 (compatible; SQLSpider/1.5). Por la hora y el patrón del mensaje, la tabla no guardó ninguno: la única fila guardada en esa hora es un discurso de venta de generación de leads, el patrón de la dirección estadounidense que también envió en esa hora.7820

Quién enviaba

Cloudflare etiqueta las solicitudes de la red Tor con un código de país especial, T1. Su referencia de cabeceras dice «T1 - Used for clients using the Tor network» (T1: se usa para clientes que usan la red Tor), y su página de reglas de IP Access dice «T1: Tor exit nodes (country name: Tor)» (T1: nodos de salida de Tor, con Tor como nombre de país).5152 Según ese código, 23 de los 52 envíos vinieron de Tor, el 44,2 %, y 23 de las 35 direcciones distintas eran salidas de Tor, el 65,7 %.7 Como verificación cruzada, comparé cada dirección que envió el formulario con la lista masiva de salidas del Tor Project, que el proyecto llama «a nearly real time authoritative source for Tor exits that allow contacting your server on port 80» (una fuente autorizada, casi en tiempo real, de las salidas de Tor que permiten contactar con tu servidor en el puerto 80). Las 23 direcciones que Cloudflare codificó como T1 estaban en la lista que descargué el 3 de noviembre, y ninguna de las otras 12.5354 Las dos fuentes coinciden. Esa lista es la de las salidas del 3 de noviembre, eso sí, así que confirma el código para ese día y no prueba qué direcciones eran salidas el día en que llegó cada envío. El archivo CollecTor del proyecto conserva listas de salidas pasadas y resolvería la pertenencia en el día del evento; no lo consulté.55

La mayoría de las direcciones de esta ventana enviaron una sola vez: 32 de las 35, el 91,4 %, y todas las salidas de Tor entre ellas.7 Las tres que enviaron más de una vez fueron el SQL spider con 13, una dirección en Polonia con dos en una hora y una dirección en Estados Unidos con cinco. Los cinco envíos de esta última caen en las mismas cinco horas que los cinco discursos de generación de leads que la tabla guardó en la ventana, y cambió de agente de usuario cada vez: Chrome 135 en Linux, luego Firefox 128, 137, 138 y 137 otra vez.720

Los agentes de usuario se dividieron por la misma línea. De los envíos de Tor, 18 nombraban un dispositivo Android y 5 un Chrome de escritorio, y 21 de los 23 nombraban Chrome 81 o anterior. Los envíos que no venían de Tor nombraban navegadores de escritorio mucho más nuevos, 10 Chrome de escritorio del 125 al 135 y 6 Firefox del 128 al 138, más los 13 del spider con su propio nombre.7

Quién quedó guardado

Las dos mitades se encontraron en el honeypot. Ninguna fila guardada en la ventana cae en una hora que solo tuvo envíos de Tor, y las 10 caen en una hora con un envío ajeno a Tor. 22 de los 23 envíos de Tor cayeron en horas sin ninguna fila guardada, y el 23.º comparte su hora con la dirección estadounidense de generación de leads.20 Hice la correspondencia por hora porque los logs de solicitudes y la tabla de mensajes no comparten ningún ID de solicitud, así que la correspondencia es una inferencia: según la hora en que llegaron, cada envío de Tor en la ventana fue una caída en el honeypot.20 Lo que se guardó, y por tanto podía llegar a la bandeja, vino de direcciones fuera de Tor, que enviaban cadenas de navegador de escritorio más nuevas y dejaban el honeypot vacío.

Las cifras encajan con el viejo patrón del spam en formularios. La taxonomía de Batchelder de 2007 divide a los creadores de spam en «Playback spambots, form-filling spambots, and humans.» (spambots que reproducen envíos, spambots que llenan formularios y humanos).49 El goteo de Tor se comporta como los que llenan formularios, que llenan todos los campos y caen en el honeypot. Los remitentes que pasaron se comportan como algo más cercano a una persona frente a un teclado, o a un script hecho para parecerlo. OWASP clasifica esta actividad como OAT-017, Spamming: «Malicious or questionable information addition to public or private content, or messages.» (adición de información maliciosa o dudosa a contenido público o privado, o a mensajes).56 OWASP también deja fuera una cosa: «The mass abuse of broken form-to-email and form-to-SMS functions, to send messages to unintended recipients, is not included in this threat event» (el abuso masivo de funciones rotas de formulario a correo o a SMS para enviar mensajes a destinatarios no previstos no está incluido en esta amenaza).56 Mis formularios solo me escriben a mí, así que son el caso incluido, spam dirigido al dueño del sitio, y no ese otro.

El goteo también vence al límite de tasa por diseño. La guía de OWASP contra el relleno de credenciales nombra el efecto de repartir las solicitudes entre muchas direcciones: «This may defeat both IP block-lists and rate limiting, as per IP request volume may remain relatively low» (esto puede vencer tanto las listas de bloqueo de IP como el límite de tasa, porque el volumen por IP puede seguir siendo relativamente bajo).50 Un envío por dirección es el volumen más bajo posible.

Qué dicen los mensajes guardados

La tabla de mensajes se remonta al 6 de junio y tenía 108 filas cuando la leí; la más reciente llegó a las 07:24 UTC del 3 de noviembre.8 Las clasifiqué por patrón con reglas fijas, la primera coincidencia gana:57

Patrón Tabla completa Octubre
«Hi, I wanted to know your price» (hola, quería saber su precio), un idioma por fila, un solo nombre de remitente 49 10
Ofertas de proveedores: leads web, crecimiento en Instagram, video explicativo, SEO, datos B2B, un directorio, diseño 22 4
Cebos de premios: jackpot, código promocional, PlayStation, Lamborghini, enlaces de Rolls-Royce, un solo nombre de remitente 16 3
Un enlace de «$50,000,000 per month» (50 millones de dólares al mes) en telegra.ph 9 9
Un discurso de generación de leads: «If I were a real customer, how soon would I get a call back?» (si yo fuera un cliente real, ¿en cuánto tiempo me devolverían la llamada?) 6 5
Otros: dos cadenas sin sentido, una solicitud de lista de correo, una nota pidiéndome que escribiera a otro lugar 4 1
Mi propia prueba del notificador por correo, 21 de septiembre 1 0
Una consulta real, según mi lectura 1 0

Algunos merecen una mirada más de cerca. Las nueve filas de los 50 millones llegaron con un mismo nombre de remitente y nueve direcciones de remitente distintas, cada una una pequeña variación del mismo titular.8 La pregunta del precio aparece en 25 idiomas en toda la tabla: afrikáans, armenio, euskera, bielorruso, bengalí, búlgaro, catalán, croata, danés, inglés, georgiano, griego, húngaro, islandés, indonesio, irlandés, italiano, latín, letón, lituano, luxemburgués, español, vietnamita, galés y zulú. Solo en octubre llegó en nueve: armenio, euskera, danés, indonesio, latín, letón, lituano, español y vietnamita.57 Dos de las identificaciones de idioma son mías y ajustadas: la frase que leí como croata podría ser bosnio, y la que leí como indonesio podría ser malayo.57 Alguien está paseando un libro de frases por un formulario de contacto, un idioma a la vez.

El ritmo es la otra firma del goteo. Octubre guardó 32 filas, 1,03 por día, en 21 de sus 31 días, y nunca más de tres en un día.8 Ningún límite de tasa por dirección salta con uno al día.

Cuáles llegaron a la bandeja

El formulario envía correos a la bandeja del estudio solo desde el 21 de septiembre a las 17:46:47 UTC. Desde entonces ha guardado, y por tanto enviado, 43 filas, un límite superior porque la llamada de notificación es de mejor esfuerzo y no he verificado la hora del despliegue; las 65 filas guardadas antes de esa fecha nunca se enviaron por correo.258 Cada fila de la ventana de evidencia es spam.57

Aparte de mi propia prueba, una fila de toda la tabla no lo es. El 14 de septiembre, un cliente de uno de los productos del estudio escribió sobre un error concreto, con voz de persona, y el mensaje se quedó en la tabla sin que nadie lo leyera.57 Cuando conecté el formulario al correo una semana después, el mensaje del commit explicaba por qué con palabras simples: «a paying customer’s message sat unread in D1 for a week» (el mensaje de un cliente que paga se quedó sin leer en D1 durante una semana).25 Ese mensaje es la razón de que exista el notificador, y es la razón por la que no puedo arreglar el spam apagando el notificador. Mantengo anónimo a su remitente; lo importante es que una persona real está en la misma tabla que el libro de frases.

6. Qué es una verificación de bots, y qué se desplegó

Turnstile, en palabras de Cloudflare

Cloudflare llama a Turnstile su «smart CAPTCHA alternative» (alternativa inteligente al CAPTCHA), y dice que «can be embedded into any website without sending traffic through Cloudflare and works without showing visitors a CAPTCHA.» (puede integrarse en cualquier sitio web sin pasar el tráfico por Cloudflare y funciona sin mostrar un CAPTCHA a los visitantes).21 El mecanismo es la parte que responde a mi pregunta. «First, we run a series of small non-interactive JavaScript challenges to gather signals about the visitor or browser environment» (primero ejecutamos una serie de pequeños desafíos de JavaScript no interactivos para reunir señales sobre el visitante o el entorno del navegador), dice la descripción general, y «These challenges include proof-of-work (computational puzzles), proof-of-space, probing for web APIs, and various other challenges for detecting browser-quirks and human behavior.» (estos desafíos incluyen prueba de trabajo, con acertijos computacionales, prueba de espacio, sondeo de APIs web y otros desafíos para detectar peculiaridades del navegador y comportamiento humano).21 Un límite de tasa pregunta cuántos. Un token CSRF pregunta si el cliente tiene un token emitido por el sitio. Turnstile pregunta qué tipo de cliente hay del otro lado. Mi desafío por agente de usuario también podía preguntarlo, pero solo a las solicitudes cuya cadena de agente de usuario estaba en su lista, y mi desafío por puntuación de amenaza nunca podía activarse; la diferencia que importa aquí es la de un desafío que se activa de forma selectiva frente a una verificación exigida a cada envío.

El widget tiene tres modos. Uso el gestionado, que Cloudflare describe como uno que «Automatically chooses between non-interactive or checkbox challenge based on visitor risk level. No images or text to decipher.» (elige automáticamente entre un desafío no interactivo o una casilla según el nivel de riesgo del visitante; sin imágenes ni texto que descifrar).58 En términos simples, un visitante ve una verificación breve y nada más, salvo que las señales pidan más, y entonces recibe una casilla que marcar. El modo invisible oculta incluso eso, y Cloudflare lo condiciona: un sitio que lo activa debe mencionar el Turnstile Privacy Addendum de Cloudflare en su propia política de privacidad.58 Turnstile tiene un plan gratuito propio, de hasta 20 widgets, con desafíos ilimitados, y Cloudflare dice que «can be used independently without requiring other Cloudflare services.» (puede usarse de forma independiente sin requerir otros servicios de Cloudflare).59

La mitad del servidor, que es la que cuenta

El widget por sí solo no hace nada por un formulario, y Cloudflare lo dice en las primeras líneas de su guía del lado del servidor: «You must call the Siteverify API to complete your Turnstile implementation. The client-side widget alone does not protect your forms.» (debes llamar a la API Siteverify para completar tu implementación de Turnstile; el widget del lado del cliente por sí solo no protege tus formularios). Y luego, más tajante: «Tokens can be forged. An attacker can submit any string to your form endpoint without completing a challenge.» (los tokens pueden falsificarse; un atacante puede enviar cualquier cadena al endpoint de tu formulario sin completar un desafío).60 El servidor envía el token a https://challenges.cloudflare.com/turnstile/v0/siteverify con su clave secreta, y puede incluir la dirección IP del visitante como remoteip.60

Dos propiedades del token dan forma al código que lo rodea. «Each token is valid for 300 seconds (5 minutes) after generation.» (cada token es válido durante 300 segundos, 5 minutos, desde su generación). Y «Tokens are single-use. Each token can only be validated once. A replayed token will be rejected with the timeout-or-duplicate error code.» (los tokens son de un solo uso; cada token solo puede validarse una vez, y uno reutilizado se rechaza con el código de error timeout-or-duplicate).60 Un escáner que hubiera obtenido un token no podría enviar el formulario 1.065 veces con él. Cloudflare también limita el token a 2.048 caracteres y le pide al servidor «Validate the action and hostname when specified.» (validar la acción y el nombre de host cuando se especifiquen).60 Una advertencia de la misma página: nombra timeout-or-duplicate para un token vencido en un lugar y describe invalid-input-response como «Token is invalid, malformed, or expired» (el token no es válido, está mal formado o vencido) en su tabla de errores, así que un servidor debería tratar cualquier fallo como un rechazo en lugar de basar su lógica en un solo código.60

Qué procesa, en palabras de Cloudflare

Cloudflare no afirma que Turnstile no use datos personales, y yo tampoco lo afirmaré. Su anexo de privacidad de Turnstile nombra las señales: «Cloudflare Turnstile processes a variety of client-side signals (“Signals”) such as client IP address, TLS Fingerprint, User-Agent Header and Sitekey and associated origin.» (Turnstile procesa diversas señales del lado del cliente, como la dirección IP, la huella TLS, la cabecera User-Agent y la sitekey con su origen asociado). Declara el propósito: «The purpose of collecting these Signals is not to identify, profile or target any individuals but solely to detect and block bots.» (el propósito de recopilar estas señales no es identificar, perfilar ni dirigirse a ninguna persona, sino solo detectar y bloquear bots). Y dice que Cloudflare también las usa para mejorar el producto: «Cloudflare is a data controller of Signals that we process to improve Turnstile’s bot detection capabilities.» (Cloudflare es responsable del tratamiento de las señales que procesa para mejorar la detección de bots de Turnstile).22 Lo que Cloudflare sí dice que deja en paz es el contenido: «Turnstile does not access, store, or transmit user communications, form entries, or other page inputs.» (Turnstile no accede a las comunicaciones del usuario, lo que se escribe en los formularios ni otras entradas de la página, ni las guarda ni las transmite).21 La página comercial añade que «Turnstile never harvests data for ad retargeting.» (Turnstile nunca recolecta datos para retargeting publicitario).61 Mi lectura: Turnstile ve el mismo tipo de datos de conexión que mi borde ya registra para cada solicitud, y nunca ve lo que una persona escribe en mi formulario.

La CSP que necesita

Turnstile se carga desde el origen de desafíos de Cloudflare, y una Content Security Policy tiene que permitirlo. Cloudflare lista dos directivas: «script-src: https://challenges.cloudflare.com» y «frame-src: https://challenges.cloudflare.com».62 Mis dos zonas fijan su política en una regla de cabeceras de respuesta de Cloudflare. En este sitio la lista de frames ya existía y ganó el origen de desafíos. El sitio del estudio no tenía ningún frame-src, así que su default-src 'self' habría bloqueado el iframe del widget; ganó frame-src 'self' más el origen de desafíos, y la política propia de la aplicación en el código ganó lo mismo.12

Por qué no un desafío gestionado sobre el propio POST del formulario

El atajo obvio ya estaba en mi zona: una regla personalizada que responde a un POST al formulario con un desafío gestionado. Mis reglas actuales lo hacen para curl y sus parientes, y funciona, porque ninguna persona usa curl para enviarme un mensaje.939 Extenderlo a todos los POST habría roto el formulario para todo el mundo. Cloudflare documenta la limitación directamente: «Challenge Pages interrupt the request flow by returning a full HTML page for the user’s browser to render and solve. This mechanism fails when the browser expects a non-HTML response, such as an AJAX or XHR (fetch) request.» (las páginas de desafío interrumpen el flujo de la solicitud devolviendo una página HTML completa para que el navegador la muestre y la resuelva; este mecanismo falla cuando el navegador espera una respuesta que no es HTML, como una solicitud AJAX o XHR).63 El formulario de contacto de este sitio es un formulario HTMX, que envía con hx-post e inserta la respuesta en la página, así que su POST es justo ese tipo de solicitud; la documentación no menciona HTMX por su nombre, y la conexión es mi lectura.24 Un POST desafiado volvería como una página de desafío HTML, que Cloudflare marca con una cabecera cf-mitigated «set to challenge» (con el valor challenge), y «Regardless of the requested resource-type, the content-type of a challenge will be text/html.» (sea cual sea el tipo de recurso solicitado, el content-type de un desafío será text/html).64 El formulario insertaría una página de desafío en su recuadro de respuesta, o nada.

La respuesta de la propia Cloudflare para ese caso es otra función: «To ensure your API calls are protected without breaking single-page applications (SPAs) or API integrations, Cloudflare recommends using Turnstile Pre-clearance.» (para proteger tus llamadas a la API sin romper aplicaciones de una sola página ni integraciones de API, Cloudflare recomienda usar Turnstile Pre-clearance).63 Con la preautorización, un widget resuelto también emite una cookie cf_clearance que «enables visitors to bypass WAF Challenges on all subsequent requests on the zone, including API requests, based on the security clearance level set by the customer.» (permite a los visitantes saltarse los desafíos del WAF en todas las solicitudes posteriores a la zona, incluidas las de la API, según el nivel de autorización fijado por el cliente). La cookie no sustituye al token: Cloudflare dice que «is issued to the visitor in addition to the default Turnstile token» (se emite al visitante además del token predeterminado de Turnstile), y la misma página sigue exigiendo la verificación del servidor: «You must call Siteverify to complete your Turnstile configuration.» (debes llamar a Siteverify para completar tu configuración de Turnstile).65 Lo que añade la preautorización es un desafío en el borde al que un POST por XHR puede sobrevivir, para un visitante cuya cookie tiene una autorización válida del nivel del desafío o superior. Yo usé lo predeterminado: un widget en el formulario y un token verificado por mi propio servidor.

Qué se desplegó el 3 de noviembre

En ambos sitios creé un widget en modo gestionado mediante la API de Cloudflare, limitado al dominio propio del sitio. El secreto de cada widget pasó de la respuesta de la API al llavero de mi Mac y a las variables del servicio de hosting en el mismo proceso, y nunca a una línea de comandos ni a un archivo.12

Este sitio. Un nuevo verificador envía el token a siteverify con la dirección del visitante. Un token ausente, un token de más de 2.048 caracteres, un token rechazado o un fallo de transporte devuelven false, así que el formulario falla cerrado siempre que el secreto esté configurado; sin secreto, como en el desarrollo local y en la batería de pruebas, el formulario funciona como antes.12 Un token que Cloudflare acepta también tiene que haberse servido en uno de los nombres de host de este sitio, y cuando Cloudflare devuelve la acción del widget, tiene que ser la del propio formulario, como aconseja la guía del lado del servidor; una respuesta sin nombre de host es un rechazo. Así, un token válido generado en otro sitio, o por otro widget, no pasa. Esa verificación entró como un segundo cambio esa misma mañana, compilado desde las 07:20 PST; a las 07:25 PST, tras purgar la caché, un envío con un token CSRF válido de la página en vivo y sin token de Turnstile volvió con 403 de nuevo. Eso prueba que el formulario sigue fallando cerrado después del despliegue; un envío sin token no puede ejercitar la verificación del nombre de host en sí, que descansa en el código y en las pruebas.6660 Hasta un tercer cambio, ambos widgets y ambos verificadores también admitían localhost, y como una sitekey es pública, un token generado con la mía en el localhost de cualquiera habría pasado tanto siteverify como la verificación del nombre de host. La página de pruebas de Cloudflare desaconseja eso: «Cloudflare recommends that sitekeys used in production do not allow local domains» (Cloudflare recomienda que las sitekeys usadas en producción no permitan dominios locales), y nombra localhost entre ellos. A las 08:12 PST quité localhost de ambos widgets, y ambos verificadores admiten ahora por defecto solo el sitio y su host www, así que la verificación admite los nombres de host propios de este sitio y nada más; el desarrollo local nombra sus nombres de host en una configuración. Tras ese cambio, ambos sitios volvieron a pasar las mismas verificaciones en vivo, a las 08:15 y a las 08:18, hora del Pacífico: el widget en la página y un envío sin token rechazado con un 403. Ninguna verificación en vivo puede ejercitar la regla del nombre de host en sí, porque un token ausente o basura falla antes de que se lea el nombre de host; esa regla descansa en la configuración del widget, el código y las pruebas.4667 El orden de verificaciones del controlador es ahora CSRF, luego Turnstile y luego el envío, detrás del mismo limitador por dirección que antes. Ambos rechazos devuelven el propio fragmento de error de la página con un 403, y HTMX no inserta por defecto una respuesta 4xx en la página, así que el formulario necesita un manejador que deje pasar un 403. Mi primera versión de ese manejador no funcionó. La adjunté al formulario, pero HTMX dispara el evento en el elemento que recibe la respuesta, y en esta página ese elemento es el hermano del formulario, así que el evento nunca llegaba al manejador y una persona rechazada seguía sin ver nada. Una segunda revisión del borrador de este post lo detectó leyendo el HTMX empaquetado frente a la plantilla. El arreglo, un cuarto cambio esa mañana, pone el listener en el body de la página, limitado al recuadro de respuesta, y una prueba verifica que se renderiza. A las 08:21 PST, tras ese despliegue, ejecuté el formulario en el Chromium de Playwright con el token del widget reemplazado por una cadena basura: el recuadro de respuesta mostró «error: Verification failed. Reload the page and try again.» (error: falló la verificación; recarga la página e inténtalo de nuevo), y no se envió nada, porque siteverify rechaza un token basura antes de que el controlador llegue al envío.68 El viejo rechazo CSRF, el 403 en JSON que HTMX nunca mostraba, ya no existe. El formulario reinicia su propio widget después de cada solicitud HTMX, nombrando el contenedor del widget tal como lo recibe la llamada de reinicio de Cloudflare, así que un reintento lleva un token nuevo, porque cada token es de un solo uso y vive 300 segundos.126069 La guía del lado del cliente de Cloudflare da la razón y la llamada: «To reset the widget if the given widget timed out or expired, you can use the function:» (para reiniciar el widget si se agotó su tiempo o venció, puedes usar la función:) turnstile.reset(widgetId);.69 Mi primera versión llamaba al reinicio sin argumento, algo que la guía no documenta; una cuarta ronda de revisión lo detectó, la llamada nombra ahora el contenedor en ambos sitios, y una verificación en el navegador el 3 de noviembre envió un token basura en cada formulario, vio el rechazo y luego encontró un token nuevo en el campo oculto: en menos de ocho segundos en el sitio del estudio, en menos de veinticinco en este, donde el widget volvió a ejecutar primero su desafío.12 El div del widget y el script solo se renderizan cuando la clave del sitio está configurada. Pasan once pruebas: sin secreto, envía como antes; con secreto, el controlador rechaza un token ausente antes de cualquier envío y rechaza un token rechazado, un token aceptado envía, y CSRF sigue ejecutándose primero; y el verificador acepta el nombre de host de este sitio y rechaza otro nombre de host, localhost por defecto, otra acción o ninguna.126646

El sitio del estudio. El honeypot sigue delante. Un campo oculto lleno sigue respondiendo con el mensaje de éxito y no guarda nada, así que los bots más baratos nunca cuestan una llamada a Cloudflare. Luego viene Turnstile, y un token ausente, rechazado o imposible de verificar recibe un 403 con el fragmento de error. Luego la validación, la inserción en la tabla de mensajes y la notificación.12 Su verificador revisa el nombre de host (ahora solo los dos nombres de host propios del sitio) y la acción del mismo modo; ese cambio se desplegó a las 07:19 PST, y a las 07:22 PST la página en vivo llevaba el widget y la clave del sitio, un envío sin token y un envío con token basura volvieron con 403, y un envío al honeypot siguió respondiendo 200.66 Antes de desplegar, ejecuté el controlador en local contra las claves de prueba documentadas por Cloudflare. Con el secreto que siempre aprueba, un envío sin token volvió con 403 y el token ficticio pasó Turnstile y llegó a la validación; con el secreto que siempre rechaza, ambos volvieron con 403; el honeypot respondió 200.1267 La página de pruebas de Cloudflare explica por qué un token ficticio no puede pasar en producción: «Production secret keys: Only accept real tokens, reject dummy tokens.» (claves secretas de producción: solo aceptan tokens reales y rechazan los ficticios).67

La verificación en vivo. Más temprano esa mañana, después de que el despliegue de cada sitio terminara con éxito y de una purga de caché, a las 06:35 PST, cada página de inicio llevaba un div del widget, una etiqueta de script de Turnstile y la clave del sitio, y la política en vivo de cada zona listaba el origen de desafíos en frame-src. Un envío al formulario de este sitio con un token CSRF basura volvió con 403, pero eso es la verificación CSRF rechazándolo, y no prueba nada sobre Turnstile. La verificación de Turnstile en sí la probé a las 06:43 PST: un envío con un token CSRF válido tomado de la página en vivo y sin token de Turnstile volvió con 403 y el mensaje «error: Verification failed. Reload the page and try again.», y el mismo envío con un token de Turnstile basura también volvió con 403. Así que el secreto está en el servicio y la verificación se ejecuta después de CSRF. En el sitio del estudio, un envío sin token y un envío con token basura volvieron ambos con 403, y un envío al honeypot volvió con 200 sin guardar ni enviar nada.70

La verificación en el navegador. En un Chromium real controlado por Playwright, el widget se renderizó en ambas páginas, el script de Turnstile se cargó y el campo oculto cf-turnstile-response tenía un token de 730 caracteres en ambas páginas en menos de seis segundos.70 Eso prueba menos de lo que parece. La página de pruebas de Cloudflare advierte que «Automated testing suites (like Selenium, Cypress, or Playwright) are detected as bots by Turnstile» (Turnstile detecta como bots las baterías de pruebas automatizadas, como Selenium, Cypress o Playwright), y solo siteverify decide si un token pasa.67 Lo que prueba la verificación es que el widget funciona bajo ambas políticas y llena su campo. Lo que no he hecho es la prueba que más le importa a un lector de este post: una persona que llena el formulario y recibe una respuesta. Esa prueba envía un correo, así que me toca a mí hacerla, y todavía no la he hecho.

7. Para qué sigue sirviendo el límite de tasa

Turnstile reemplazó a los límites de tasa como puerta. No los reemplazó como cerca. Una verificación de bots responde quién; algo tiene que seguir respondiendo cuántos, porque cada envío que llega a la verificación con un token no vacío de tamaño válido cuesta ahora una llamada a la API siteverify de Cloudflare, y cada envío que pasa sigue costando un envío de correo.12 La propia guía del lado del servidor de Cloudflare incluye el límite de tasa entre sus recomendaciones para la llamada a siteverify en sí: «Rate limit to protect against validation flooding.» (aplica un límite de tasa para protegerte de las avalanchas de validaciones).60 Mis dos límites se quedan: la regla del borde de 3 envíos cada 10 segundos por dirección y por centro de datos, y el limitador de la aplicación de cinco envíos por minuto por dirección.917

La regla del borde merece una descripción honesta ahora que sé cómo cuenta. Cloudflare expone el diseño sin rodeos: «Cloudflare tracks request rates by maintaining separate counters for each unique combination of values in a rule’s characteristics» (Cloudflare sigue las tasas de solicitudes manteniendo contadores separados para cada combinación única de valores de las características de una regla), y «Cloudflare does not support global rate limiting counters across the entire network.» (Cloudflare no admite contadores globales de límite de tasa en toda la red).6 Una dirección que llega a mi zona por dos centros de datos recibe dos cupos, y los envíos del escáner del 2 de noviembre sí llegaron por dos; esa es mi explicación más sólida de por qué la regla guardó silencio, coherente con los totales por minuto pero no probada por ellos.5 Añade la advertencia de precisión de Cloudflare, que las reglas «are not designed to allow a precise number of requests to reach your origin server» (no están diseñadas para dejar llegar un número preciso de solicitudes al servidor de origen), y la mejor descripción de la regla es un presupuesto nominal, no un tope probado: 3 envíos del formulario cada 10 segundos por dirección y por centro de datos, el equivalente nominal de 18 por minuto, aplicado de forma aproximada.489 En el plan Free es además la única regla de límite de tasa que tengo, con un periodo fijo de 10 segundos y conteo solo por IP.48

Un detalle del plan Free importa si te tentó, como a mí, convertir el propio límite de tasa en el desafío. La página de parámetros de Cloudflare dice que en los planes Free, Pro y Business una regla de límite de tasa con acción de desafío siempre limita el caudal en lugar de mantener una duración, y que «When visitors pass a challenge, their corresponding request counter is set to zero.» (cuando los visitantes superan un desafío, su contador de solicitudes vuelve a cero).71 Un desafío sobre el POST por XHR del formulario también fallaría por la razón de la sección 6.63 Así que la regla del borde sigue siendo un bloqueo, y el desafío vive en el widget.

El limitador de la aplicación necesita el arreglo que pide su fuga: un almacén de contadores con un incremento atómico, para que dos envíos no puedan leer el mismo conteo, y una ruta de fallo que no divida el conteo entre procesos. El código actual, una lectura de KV seguida de una escritura aparte con un respaldo por proceso, no ofrece ninguna de las dos cosas, así que esto es un cambio de almacén y no un parche de una línea.173 Con Turnstile como condición añadida para cada envío que podría llegar a la bandeja, un envío que la fuga deje pasar todavía tiene que llevar un token que Cloudflare acepte, así que la fuga cuesta volumen y dinero, y la decisión de quién puede enviar ya no descansa en el conteo. Un limitador es un presupuesto. Un presupuesto que se desvía en 47 en una avalancha es un mal presupuesto, pero nunca fue un guardia.

La guía de autenticación de OWASP plantea la misma estratificación en sus propios términos. Trata un CAPTCHA «as a defense-in-depth control to make brute-force attacks more time-consuming and expensive, rather than as a preventative.» (como un control de defensa en profundidad para que los ataques de fuerza bruta lleven más tiempo y cuesten más, y no como una medida preventiva).72 Y su guía contra el relleno de credenciales señala que «Most tools used for these types of attacks will make direct POST requests to the server and read the responses, but will not download or execute JavaScript» (la mayoría de las herramientas usadas en estos ataques hacen solicitudes POST directas al servidor y leen las respuestas, pero no descargan ni ejecutan JavaScript).50 Mis dos escáneres sí descargaron las páginas del sitio, la vía más probable por la que llegaron a tener tokens CSRF válidos. Un cliente que envía el formulario sin ejecutar el widget no tiene ningún token de Turnstile que enviar, y ambos formularios rechazan ahora un envío sin él: en este sitio, un envío con un token CSRF válido y sin token de Turnstile volvió con 403, y lo mismo un envío sin token en el sitio del estudio.70

La lista de verificación

Para un formulario de contacto detrás de Cloudflare, en el orden en que lo haría ahora:

  1. Pon una verificación de bots en el formulario y verifica cada token en el servidor. En palabras de la propia Cloudflare: «The client-side widget alone does not protect your forms.» (el widget del lado del cliente por sí solo no protege tus formularios).60 Verifica también el nombre de host y la acción en la respuesta, como aconseja la misma página, para que un token generado en otro lugar no pase, y deja los dominios locales fuera de la sitekey de producción, porque «Cloudflare recommends that sitekeys used in production do not allow local domains».606667
  2. Falla cerrado. Un token ausente, demasiado largo, rechazado o imposible de verificar es un rechazo, porque «Tokens can be forged.» (los tokens pueden falsificarse).6012
  3. Reinicia el widget después de cada envío. Los tokens son de un solo uso y valen 300 segundos, así que un reintento sin reinicio es un reintento con un token gastado.6069
  4. Permite el origen de desafíos en ambas directivas de la CSP. Cloudflare pide script-src y frame-src para https://challenges.cloudflare.com, y una política con solo default-src 'self' bloquea el iframe.6212
  5. No pongas un desafío gestionado en un formulario que envía con XHR o fetch. Cloudflare dice que la página de desafío «fails when the browser expects a non-HTML response» (falla cuando el navegador espera una respuesta que no es HTML), y un envío HTMX es ese tipo de solicitud.63
  6. Devuelve un error que una persona pueda ver. Mi rechazo CSRF era un 403 en JSON que el formulario HTMX nunca mostraba, así que una persona con un token vencido habría hecho clic en Enviar y no habría visto nada, y mi primer arreglo escuchaba en el formulario mientras HTMX dispara el evento de inserción en el destino de la respuesta, el hermano del formulario, así que prueba el rechazo en un navegador.1268
  7. Mantén el honeypot delante de la verificación de bots. Atrapó 42 de 52 envíos en el sitio de mi estudio al costo de un campo oculto y un if.725
  8. Mantén el límite de tasa, y dimensiónalo como tope de costo, no como puerta. Cloudflare lo cuenta por dirección y por centro de datos, y el escáner del 2 de noviembre llegó por dos, la razón más probable de que nunca se activara, aunque los totales por minuto no pueden probarlo.65
  9. Respalda el limitador de la aplicación con un almacén que incremente de forma atómica. El mío leía el conteo y lo volvía a escribir en una solicitud aparte, lo reiniciaba a uno cuando fallaba su segunda lectura, recurría a la memoria de un solo proceso cuando fallaba KV, y dejó pasar 47 y 9 envíos por encima de su propio techo; los logs no pueden decir qué ruta tuvo la fuga.173
  10. No confíes en una regla de agente de usuario para detener a un escáner. Ambos escáneres enviaron una cadena simple de Chrome, al menos 69.322 veces el 30 de octubre y 9.725 veces el 2 de noviembre.3241
  11. Cuenta los correos en el proveedor de correo, no en el formulario. Mi controlador responde 200 en sus rutas de fallo, y el evento email.sent de Resend solo significa que «the API request was successful» (la solicitud a la API tuvo éxito); el campo last_event de un correo consultado dice qué pasó después.242873
  12. Consulta los análisis antes de que pase un mes. Los conjuntos de datos adaptativos de Cloudflare conservan «at least 31 days of data on every plan» (al menos 31 días de datos en todos los planes).27
  13. Vuelve a contar cuando la ventana se haya cerrado. Mis notas escritas minutos después de la avalancha del 30 de octubre se quedaron cortas en los envíos del formulario, porque Cloudflare no había terminado de ingerir los últimos minutos.16
  14. Trata un bloqueo de dirección como limpieza. El mío llegó unos 50 minutos después del primer envío del formulario el 30 de octubre y 8 minutos después del inicio del escaneo el 2 de noviembre, y ningún envío de ninguno de los dos días llegó después del minuto del bloqueo: 101 antes el 30 de octubre, y 26 antes y 5 dentro el 2 de noviembre.1192

Qué podría cambiar

Son condiciones, no pronósticos. Cada una nombra lo que tendría que pasar y lo que yo haría al respecto.

Si un escáner empieza a superar los desafíos gestionados. Las verificaciones de Turnstile se ejecutan en el navegador y buscan automatización; una herramienta que ejecute un navegador completo lo bastante bien como para superarlas, a escala, convertiría el widget en un costo más para ella y no en un muro. La señal serían envíos del formulario con tokens que siteverify acepta, desde direcciones que también envían cadenas de inyección en la cabecera User-Agent. Batchelder escribió la versión honesta de esto para los honeypots en 2007: «If and when they do, we can switch back to CAPTCHAs.» (si llegan a hacerlo, y cuando lo hagan, podemos volver a los CAPTCHA).49 Mi alternativa sería la misma en espíritu, un desafío más fuerte, y mientras tanto el honeypot y los límites de tasa siguen en pie.

Si añado una verificación en el borde con preautorización. Cloudflare recomienda la preautorización de Turnstile para las solicitudes que no pueden mostrar una página de desafío, y una cookie cf_clearance permite a un visitante «bypass WAF Challenges on all subsequent requests on the zone, including API requests» (saltarse los desafíos del WAF en todas las solicitudes posteriores a la zona, incluidas las de la API).6563 Si la adopto, el borde podría desafiar el propio POST del formulario sin romper HTMX para un visitante cuya cookie tenga una autorización válida del nivel del desafío o superior, y la página de CSP de Cloudflare añade un requisito para ese modo: «your connect-src directive must include ‘self’.» (tu directiva connect-src debe incluir ‘self’).62 La cookie llega «in addition to the default Turnstile token» (además del token predeterminado de Turnstile), y siteverify sigue siendo obligatorio, así que el desafío del borde sería una segunda verificación delante de la de mi servidor, no un reemplazo.65 No lo he hecho, y el post describe el diseño de widget y siteverify que está en producción.

Si exporto el log de Resend para las dos ventanas. Cada conteo de correos de este post es un techo, porque un 200 del formulario es un envío, o un fallo al que mi controlador también responde con 200.24 El endpoint de consulta de Resend devuelve un last_event para cada correo, como delivered, y su lista de eventos distingue «sent - The email was sent successfully.» (enviado: el correo se envió con éxito) de «delivered - Resend successfully delivered the email to the recipient’s mail server.» (entregado: Resend entregó el correo al servidor de correo del destinatario).7374 Con esa exportación, 101 y 31 se convertirían en conteos exactos, y solo podrían bajar.

Si una persona envía el spam a mano. Turnstile pregunta si hay un navegador y, a veces, una persona. No pregunta por qué. Una persona a la que pagan por pegar un discurso de venta en formularios de contacto puede superarlo, y también los remitentes de generación de leads del sitio de mi estudio si hay una persona detrás. Para ese caso, las defensas son las de siempre: un límite de tasa para el volumen y mi propio criterio en la bandeja de entrada.

Si el goteo de Tor necesita una respuesta día a día. La lista de salidas que revisé es la que descargué el 3 de noviembre, y el servicio CollecTor del Tor Project conserva listas pasadas.5355 Si lo consulto para los ocho días, la atribución a Tor descansaría en las listas de los días en que llegaron los envíos, no en el código de Cloudflare más la lista del 3 de noviembre. Cloudflare también ofrece Onion Routing en todos los planes, que sirve a los usuarios de Tor directamente a través de la red Tor; Cloudflare dice que con él «Human Tor users and bots can be distinguished by our Onion services, such that interactive challenges are only served to malicious bot traffic.» (nuestros servicios Onion pueden distinguir a los usuarios humanos de Tor de los bots, de modo que los desafíos interactivos solo se sirven al tráfico de bots maliciosos).75 No lo he activado, y querría medir el tráfico de Tor del sitio del estudio después de Turnstile antes de hacerlo.

Conclusiones clave

Si tienes un formulario de contacto:

  • Un token CSRF y un límite de tasa juntos siguen dejando que un escáner te escriba. Mis dos escáneres tenían tokens válidos, y mi limitador dejó pasar 101 y 31 envíos.12
  • Verifica en el servidor el token de una verificación de bots, falla cerrado y reinicia el widget después de cada solicitud; el widget por sí solo «does not protect your forms.» (no protege tus formularios).60
  • Prueba tu limitador bajo carga y bajo fallos, no de una solicitud en una. Un contador de leer y luego escribir con un respaldo por proceso dejó pasar 18 envíos en un minuto configurado para permitir cinco.117
  • Mantén el honeypot. Es el filtro más barato de la pila, y atrapó el 80,8 % de estos envíos en el sitio de mi estudio, en ocho días, antes de que el controlador revisara una dirección, guardara una fila o enviara un correo.725

Si gestionas el borde del sitio:

  • Sabe qué cuenta tu límite de tasa. En Cloudflare cada regla cuenta por centro de datos, y un escáner que llega por dos recibe dos contadores.65
  • Sondea tus propias reglas y lee después los eventos de firewall. Once eventos de firewall de mi prueba me dijeron más sobre la regla que un día razonando sobre por qué había guardado silencio.18
  • No desafíes un POST por XHR o fetch con una página de desafío; Cloudflare documenta que falla y recomienda la preautorización para ese caso.63
  • Espera que las reglas de agente de usuario solo atrapen a las herramientas honestas. Los escáneres de aquí enviaron una cadena de navegador; mi prueba con curl provocó desafíos.3239

Si eres quien lee la bandeja de entrada:

  • Cuando la bandeja se llene, revisa el remitente antes que el asunto. Tres de mis flujos de correo comparten una dirección de remitente, y por eso mi primera suposición fue errónea.23
  • Trata los 200 de un formulario como un techo de correos, no como un conteo, hasta que el log del proveedor de correo diga otra cosa.2428
  • Lee los mensajes guardados antes de apagar el notificador. El mensaje de un cliente real se quedó una vez sin leer durante una semana, y ese mensaje es la razón de que el formulario del estudio me envíe correos.25

Preguntas frecuentes

¿Por qué mi formulario de contacto recibe spam a pesar de Cloudflare?

Lo más probable es que nada delante del formulario le pregunte a cada envío si lo mandó una persona. Las reglas personalizadas de Cloudflare comparan direcciones, redes, rutas y cadenas de agente de usuario, y sus límites de tasa cuentan solicitudes por dirección y por centro de datos; un escáner que envía un agente de usuario de navegador, carga primero la página y envía a un ritmo moderado las supera todas. En mi sitio, dos escáneres hicieron exactamente eso y metieron 101 y 31 envíos, y el spam del sitio de mi estudio llegó casi siempre a razón de un envío por dirección en los ocho días que medí, algo que ningún conteo por dirección puede atrapar.96127

¿Un límite de tasa detiene a los bots?

Un límite de tasa acota lo rápido que puede ir una dirección; no distingue un bot de una persona. La propia documentación de Cloudflare dice que sus reglas «are not designed to allow a precise number of requests to reach your origin server» (no están diseñadas para dejar llegar un número preciso de solicitudes al servidor de origen) y que los contadores no se comparten entre centros de datos. Mi regla del borde nunca se activó con ninguno de los dos escáneres, y el limitador de mi aplicación, configurado en cinco por minuto, dejó pasar 18 en un minuto: su contador es una lectura seguida de una escritura aparte, una segunda lectura fallida lo reinicia a uno y un respaldo por proceso toma el control cuando falla el almacén; cualquiera de esas rutas puede dejar pasar envíos de más.48641173

¿Basta con un campo honeypot?

Basta para los bots que llenan el campo oculto, y en el sitio de mi estudio eso fue el 80,8 % de estos envíos: el mío atrapó 42 de 52 envíos en ocho días, incluido, según la hora en que llegaron, cada envío desde una salida de Tor. No hace nada contra un remitente que deja vacío el campo oculto, y los 10 que dejó pasar, guardados y por tanto como máximo 10 correos, hicieron exactamente eso.720 Ned Batchelder, al describir la técnica en 2007, lo dijo: «In theory, it is possible for a spambot to defeat any of these measures.» (en teoría, un spambot puede vencer cualquiera de estas medidas).49

Turnstile frente a un CAPTCHA: ¿cuál es la diferencia?

Un CAPTCHA clásico le pide al visitante que resuelva un acertijo. Turnstile ejecuta «a series of small non-interactive JavaScript challenges to gather signals about the visitor or browser environment» (una serie de pequeños desafíos de JavaScript no interactivos para reunir señales sobre el visitante o el entorno del navegador), y en modo gestionado solo muestra una casilla cuando el riesgo del visitante lo pide, con «No images or text to decipher.» (sin imágenes ni texto que descifrar).2158 Cloudflare dice que sus desafíos no usan «CAPTCHA puzzles or visual tests like selecting objects or typing distorted characters.» (acertijos CAPTCHA ni pruebas visuales como seleccionar objetos o escribir caracteres distorsionados).76 Ambos siguen necesitando que el servidor verifique el resultado; para Turnstile, esa es la llamada a siteverify.60

¿Por qué la protección CSRF no detiene el spam?

Una defensa CSRF bien implementada sirve para impedir que otro sitio haga que el navegador de un visitante envíe tu formulario. Mi token es más débil que eso: no tiene estado y va firmado, vale una hora y no está ligado a una sesión, una cookie ni una dirección, así que solo prueba que el cliente tiene un token emitido por mi sitio en la última hora, no de dónde vino la solicitud. Ninguno de los dos tipos detiene a un cliente que carga tu página y envía el formulario por sí mismo, porque cargar la página es la forma en que un cliente obtiene un token válido, y ambos escáneres reunieron tokens válidos; la verificación rechazó cuatro de sus envíos en las dos avalanchas.1512

¿Cómo distingo en los logs un escáner de una persona?

Mira todo lo que hizo la dirección, no solo el formulario. Mis dos escáneres rastrearon miles de rutas, pusieron cadenas de inyección SQL en las rutas y en la cabecera User-Agent, enviaron un agente de usuario de Chrome en la mayoría de las solicitudes y uno que decía ser Googlebot en algunas que la regla de bots verificados de Cloudflare no aceptó, y enviaron el formulario en ráfagas de decenas a cientos por minuto.31324441 Las cargas basadas en tiempo como sleep(15) y waitfor delay '0:0:15' pertenecen a familias de sondas documentadas.3377 Una persona envía un mensaje, desde una página que de verdad leyó, y no le pide a tu servidor que se duerma.

¿Turnstile recopila datos personales?

Cloudflare no dice que no recopile ninguno. Su anexo de privacidad de Turnstile nombra las señales que procesa, «such as client IP address, TLS Fingerprint, User-Agent Header and Sitekey and associated origin» (como la dirección IP del cliente, la huella TLS, la cabecera User-Agent y la sitekey con su origen asociado), dice que el propósito es «solely to detect and block bots» (solo detectar y bloquear bots), y dice que Cloudflare es responsable del tratamiento de esas señales para mejorar su detección de bots. Su descripción general dice que Turnstile «does not access, store, or transmit user communications, form entries, or other page inputs.» (no accede a las comunicaciones del usuario, lo que se escribe en los formularios ni otras entradas de la página, ni las guarda ni las transmite).2221 Así que ve datos de conexión, no lo que escribe una persona.

Relacionado en este sitio. El modo auto de Claude Code no es una frontera de seguridad plantea el mismo argumento sobre otro control: un clasificador que juzga cada acción no es el muro. Agents.txt no es control de acceso traza la línea entre declararles una política a los bots y hacerla cumplir. Fatiga de alertas: 82 correos en un día de mi monitor de seguridad cuenta el escáner del 2 de noviembre desde el lado de mi monitor: un bloqueo ocho segundos después de su primer hallazgo, ocho minutos después del inicio del escaneo, y cero correos sobre él. Lo que ejecuto antes de dormir es otro tipo de rutina de operaciones, una revisión nocturna de 15.000 páginas por estado, tiempo de respuesta y caché, y Qué se rompe realmente cuando ejecuta agentes de IA sin supervisión cataloga los modos de fallo de las sesiones de agentes sin supervisión, el tipo de automatización que opera mi infraestructura.

Fuentes

Notas del autor sobre los datos: cada conteo de mis propios sistemas proviene de la GraphQL Analytics API de Cloudflare para las dos zonas (los conjuntos de datos de solicitudes y de eventos de firewall), la tabla de mensajes del sitio del estudio, el log de mi monitor y el código de los dos formularios, todo guardado el 3 de noviembre de 2026 y convertido en un registro mediante un script. Las horas están en UTC salvo que se marquen como PDT o PST. Ninguna dirección, identificador de regla o de lista, clave ni ruta del sistema de archivos de esa evidencia aparece en este post.


  1. Datos del autor. Análisis de solicitudes de Cloudflare para la zona de este sitio, los POST del escáner del 30 de octubre al formulario de contacto por minuto y por estado en el borde, consultados el 3 de noviembre de 2026 sobre la ventana cerrada: 1.065 envíos, de los cuales 101 respondieron 200, 830 respondieron 429, 130 respondieron 422 y 4 respondieron 403, todos llegando a la aplicación; primer envío y primer 200 en el minuto de las 18:00, pico de 208 envíos en el minuto de las 18:03, la mayor cantidad de 200 en un minuto 18 (18:06), ocho minutos con más de cinco 200, el último 200 a las 18:36 y el último envío que llegó a la aplicación a las 18:37. Derivado en el registro: 101 envíos antes del bloqueo, el primer envío unos 46 minutos antes de que reportara la avalancha y 49,88 minutos antes del bloqueo, el último envío que llegó a la aplicación 11,88 minutos antes del bloqueo. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩

  2. Datos del autor. Análisis de solicitudes de Cloudflare para la zona de este sitio, los POST del escáner del 2 de noviembre al formulario de contacto por minuto y por estado en el borde, consultados el 3 de noviembre de 2026: 500 envíos, de los cuales 31 respondieron 200, 31 respondieron 429 y 438 respondieron 403 en el minuto del bloqueo o después; 62 llegaron a la aplicación; primer envío en el minuto de las 18:58, pico de 32 envíos que llegaron a la aplicación en el minuto de las 19:02, la mayor cantidad de 200 en un minuto 10 (19:03), último 200 en el minuto de las 19:04; una media de 8,86 envíos por minuto en el lapso de siete minutos entre el primer envío que llegó a la aplicación, en el minuto de las 18:58, y el último, en el minuto de las 19:04; de los 31 envíos, 26 en los minutos previos al minuto del bloqueo y 5 en el propio minuto del bloqueo, las 19:04, el minuto en que la dirección entró en la lista a las 19:04:01, que los intervalos por minuto no pueden ordenar respecto a ese segundo; ninguno después. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩

  3. Análisis del autor en el registro: para cada minuto, el menor entre cinco y los envíos que vio el limitador de la aplicación (los 200, los 429 y los 403 de CSRF de la aplicación; no los 422, que FastAPI rechaza antes de que se ejecute el limitador), sumado sobre los minutos. 30 de octubre: como máximo 54 envíos para un limitador exacto frente a 101 medidos, 47 por encima, de 935 envíos que vio el limitador. 2 de noviembre: como máximo 22 frente a 31, 9 por encima, de 62. Un techo contrafáctico, no una medición; los envíos medidos son los conteos del formulario anteriores. ↩↩↩↩↩↩↩

  4. Datos del autor. Eventos de firewall de Cloudflare para la dirección del 2 de noviembre, consultados el 3 de noviembre de 2026 (un conjunto de datos muestreado, usado para saber qué regla actuó y cuándo, nunca para volúmenes): el primer evento a las 18:57:11 de la regla de rutas de exploits, el primer evento de la regla de la lista de bloqueo a las 19:04:03; cada fila un bloqueo, ninguna un desafío gestionado; 310 filas en el formulario de contacto, todas bloqueos, la primera a las 19:04:05; y ningún evento de la regla de límite de tasa para la dirección. ↩↩↩↩↩↩↩↩↩↩

  5. Datos del autor, anexo del registro. Análisis de solicitudes de Cloudflare para los POST de los dos escáneres al formulario agrupados por centro de datos de Cloudflare, minuto y estado, consultados el 3 de noviembre de 2026: el 2 de noviembre, de las 18:56 a las 19:05:00, el final de la consulta, así que el conteo solo incluye el primer envío del minuto de las 19:05, 44 envíos por Ámsterdam y 41 por Londres; en el minuto de las 19:02, 18 por Londres (4 enviados, 14 rechazados por la aplicación) y 14 por Ámsterdam (5 enviados, 9 rechazados); el mayor conteo en un minuto en un solo centro de datos, 19, fue el de Ámsterdam en el minuto de las 19:04, 15 de ellos rechazados en el borde después del bloqueo. El 30 de octubre, los 1.065 envíos llegaron por París. ↩↩↩↩↩↩↩↩

  6. Cloudflare, “How Cloudflare determines the request rate”, documentación del WAF de Cloudflare, consultada el 3 de noviembre de 2026, 14:08:57 UTC. Contadores separados por combinación de características; contadores no compartidos entre centros de datos salvo los de la misma ubicación geográfica; cf.colo.id como característica obligatoria; sin contadores globales. ↩↩↩↩↩↩↩

  7. Datos del autor. Análisis de solicitudes de Cloudflare para la zona del sitio del estudio, POST al formulario de contacto por hora, dirección, país y agente de usuario, de las 17:00 UTC del 26 de octubre a las 13:59:50 UTC del 3 de noviembre de 2026: 52 envíos, todos respondidos con 200, desde 35 direcciones; 23 envíos y 23 direcciones con el código de país de Tor de Cloudflare (44,2 % y 65,7 %); 32 direcciones con exactamente un envío (91,4 %), todas las direcciones de Tor entre ellas; las tres direcciones repetidas con 13 (Gran Bretaña, una hora, el agente de usuario SQLSpider), 2 (Polonia, una hora) y 5 (Estados Unidos, un agente de usuario nuevo cada vez); agentes de usuario de Tor: 18 Android y 5 Chrome de escritorio, 21 con Chrome 81 o anterior; agentes de usuario ajenos a Tor: 10 Chrome de escritorio del 125 al 135, 6 Firefox del 128 al 138 y 13 otros. El firewall de la zona no registró ningún evento sobre un POST al formulario en la ventana. Las caídas en el honeypot son los envíos menos las filas guardadas: 42, el 80,8 %. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩

  8. Datos del autor. La tabla de mensajes de contacto del sitio del estudio, leída el 3 de noviembre de 2026 a las 14:04 UTC: 108 filas del 6 de junio a las 07:24 UTC del 3 de noviembre, 10 de ellas en la ventana posterior a la consulta; por día como en la tabla de la sección 5; 32 filas en octubre en 21 de 31 días, 1,03 por día, nunca más de tres en un día; las nueve filas de 50.000.000 de dólares con un mismo nombre de remitente y nueve direcciones de remitente; 43 filas desde el commit del notificador y 65 antes. ↩↩↩↩↩↩↩↩↩↩↩

  9. Datos del autor. La configuración de zona de ambos sitios, exportada mediante la API de Cloudflare el 3 de noviembre de 2026: la regla de límite de tasa de este sitio (versión 1, última actualización 2026-10-30 a las 18:49:53 UTC; expresión como se cita; características centro de datos y dirección del cliente; 3 solicitudes por periodo de 10 segundos; tiempo de mitigación de 10 segundos, el equivalente nominal de 18 por minuto por dirección y por centro de datos) y sus cinco reglas personalizadas (un bloqueo por lista de cuenta y siete ASN, un bloqueo de rutas de exploits, una exención para bots verificados y dos desafíos gestionados en el formulario de contacto y las rutas de administración, por cadena de agente de usuario y por puntuación de amenaza, ambos activos en la exportación y el de puntuación de amenaza desactivado más tarde ese día); las reglas del mismo tipo del sitio del estudio, con el desafío en todo el sitio y un bloqueo en las rutas de la API que exime al formulario, y una regla de límite de tasa para todo el sitio contada por centro de datos y dirección sobre las solicitudes de clientes que no son bots verificados. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩

  10. Datos del autor. Eventos de firewall de Cloudflare para la dirección del 30 de octubre, consultados el 3 de noviembre de 2026 (3.932 filas muestreadas, por debajo del tope de la consulta, usadas solo para atribución y tiempos): el primer evento a las 17:59:53 de la regla de rutas de exploits, que actuó desde el primer minuto del escaneo; cada uno de los 111 eventos muestreados antes de las 18:49:53 de esa regla; el primer evento de la regla de la lista de bloqueo a las 18:49:53; ningún evento de la regla de límite de tasa y ningún evento en el formulario de contacto para la dirección. ↩↩↩↩↩↩↩↩↩↩↩

  11. Cloudflare, “cf.threat_score”, referencia de campos de Ruleset Engine, consultada el 3 de noviembre de 2026, 16:12:44 UTC. La descripción del campo: una puntuación que antes iba de 0 a 100, y «Now, the threat score is always 0 (zero).» ↩↩↩

  12. Registro del autor de lo que se desplegó el 3 de noviembre de 2026, escrito a partir de la salida de las herramientas de la sesión: los dos widgets (modo gestionado, creados mediante la API de Cloudflare hacia las 14:13 UTC), los cambios de CSP en ambas zonas, el verificador de este sitio y el orden de su controlador (falla cerrado, CSRF y luego Turnstile y luego el envío, fragmentos de error con 403, reinicio del widget después de cada solicitud HTMX), sus diez pruebas nuevas (10 aprobadas; una undécima llegó con la verificación del nombre de host), el orden del controlador del sitio del estudio (honeypot, luego Turnstile, luego validación, almacenamiento y notificación) y las verificaciones locales contra las claves de prueba de Cloudflare; en ambos sitios el verificador rechaza un token ausente, o uno de más de 2.048 caracteres, sin llamar a siteverify (leído del código). Ambos sitios se desplegaron entre las 14:32 y las 14:36 UTC. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩

  13. Datos del autor. Análisis de solicitudes de Cloudflare para la dirección del 30 de octubre, por minuto y en un barrido de todas las zonas de mi cuenta, consultados el 3 de noviembre de 2026: 76.429 solicitudes a este sitio del minuto de las 17:59 al de las 18:51 (53 intervalos), el mismo total en ambas consultas; 69.484 antes del minuto del bloqueo con una media de 1.389,7 por minuto, un pico de 3.219 a las 18:36, 5.234 después del minuto del bloqueo sin ninguna servida; por resultado, 49.513 servidas (64,8 %), 18.990 rechazadas por la aplicación (24,8 %, incluidas 10.019 GET 418 de la barrera de agentes del sitio), 2.339 errores 520 de origen (3,1 %), 5.587 bloqueadas en el borde (7,3 %); 50,88 minutos desde el primer minuto del escaneo hasta el bloqueo; 387 GET a kiradex.app, todos respondidos con 200, y ninguna otra zona tocada. ↩↩↩↩↩↩↩↩↩↩

  14. Datos del autor. Análisis de solicitudes de Cloudflare para la dirección del 2 de noviembre, por minuto, por ruta, por agente de usuario y en un barrido de todas las zonas, consultados el 3 de noviembre de 2026: dos GET a las 07:49 UTC, 11,1 horas antes del escaneo; 10.907 solicitudes del minuto de las 18:56 al de las 19:17 (22 intervalos), 10.909 con los GET tempranos, el mismo total en cuatro consultas; 29 solicitudes en el primer minuto, 637 en el minuto de más actividad antes del bloqueo (18:57), 1.107 en el minuto de más actividad (19:07), 300 en el último; 2.986 antes del minuto del bloqueo (una media de 373,2 por minuto), 7.445 después sin ninguna servida; por resultado, 1.388 servidas (12,7 %), 1.473 rechazadas por la aplicación (13,5 %, incluidas 420 GET 418), 116 errores 520 de origen (1,1 %), 7.930 bloqueadas en el borde (72,7 %); 8,02 minutos desde el primer minuto del escaneo hasta el bloqueo; 368 GET a kiradex.app y ninguna otra zona tocada. ↩↩↩↩↩↩↩↩↩↩↩

  15. Lectura del código por el autor. El módulo CSRF de este sitio tal como estaba desplegado el 30 de octubre y el 2 de noviembre: un token de marca de tiempo, valor aleatorio y firma HMAC-SHA256 sobre ambos; válido si la firma coincide y la marca de tiempo tiene menos de 3.600 segundos; no ligado a una sesión, cookie ni dirección. Leído del código fuente el 3 de noviembre de 2026. ↩↩↩↩

  16. Datos del autor. El registro construido por script a partir de los resultados guardados de las consultas (484 filas, cada una con su fuente y método) y su libro de códigos, 3 de noviembre de 2026, incluidas las reglas sobre qué capa respondió cada estado, la regla de que los eventos de firewall dan atribución y marcas de tiempo y los conjuntos de datos de solicitudes dan conteos, la regla de que solo un 200 prueba un token CSRF válido, y la tabla de dónde mis notas del día difieren de las consultas del 3 de noviembre (las notas del 30 de octubre se escribieron a las 11:58 PDT, antes de que Cloudflare ingiriera los últimos minutos, y se quedan cortas en los envíos del formulario, los 422, los envíos a la ruta de compartir y las solicitudes de la hora). ↩↩↩↩↩↩↩↩

  17. Lectura del código por el autor. El limitador de tasa del formulario de este sitio tal como estaba desplegado el 30 de octubre y el 2 de noviembre: un valor predeterminado de cinco envíos del formulario por dirección de cliente por ventana, una ventana de 60 segundos basada en el minuto de reloj, una lectura del contador en KV, una segunda lectura dentro del incremento que cuenta una lectura fallida como cero, y una escritura aparte en KV de ese conteo más uno, un 429 cuando el conteo leído está en el límite, y un respaldo en una lista en memoria por proceso cuando falla la llamada a KV. Leído del código fuente el 3 de noviembre de 2026. ↩↩↩↩↩↩↩↩↩↩

  18. Datos del autor, anexo del registro. Mi sondeo de la regla de límite de tasa de este sitio el 3 de noviembre de 2026 desde mi propia máquina: a las 14:36:49 UTC, diez POST sin token con 0,4 segundos de separación (cuatro 422 y luego seis 429); a las 14:38:03 UTC, ocho con todos los campos presentes (tres 403 del origen y luego cinco 429 con el cuerpo «error code: 1015»); eventos de firewall de Cloudflare para el formulario de las 14:35 a las 14:40 UTC, consultados a las 14:40:36 UTC: 11 eventos, todos bloqueos de la regla de límite de tasa. ↩↩↩↩

  19. Datos del autor. El log de mi monitor del 2 de noviembre de 2026, de las 10:40 a las 11:32 PST, y sus registros de hallazgos y bloqueos, guardados el 3 de noviembre: una ejecución iniciada a las 10:57:00 PST con una ventana de detector que terminaba a las 18:57:22 UTC y sin hallazgos, con entre 29 y 666 solicitudes de la dirección en esa ventana según los datos por minuto; la siguiente ejecución a las 11:02:46 PST; el primer hallazgo a las 11:03:53 PST (una avalancha de solicitudes crítica, 1.914 solicitudes contadas para la dirección), 7,88 minutos después del primer minuto del escaneo; la dirección añadida a la lista de bloqueo de la cuenta a las 11:04:01 PST; seis hallazgos en total, todos incorporados al resumen diario y ninguno enviado por correo. El monitor no estaba funcionando el 30 de octubre; lo volví a activar el 31 de octubre, y su detector de tráfico llegó más tarde. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩

  20. Análisis del autor. Correspondencia por intervalos de una hora entre las filas guardadas del sitio del estudio y los datos horarios de POST, 3 de noviembre de 2026: ninguna fila guardada en una hora con solo envíos de Tor; las 10 en horas con un envío ajeno a Tor; 22 de 23 envíos de Tor en horas sin ninguna fila guardada, el 23.º en una hora compartida con la dirección estadounidense de generación de leads; las cinco filas de generación de leads de la ventana en las cinco horas en que esa dirección envió. Una inferencia por hora, no un cruce por ID de solicitud. ↩↩↩↩↩↩↩

  21. Cloudflare, “Overview”, descripción general de la documentación de Turnstile, consultada el 3 de noviembre de 2026, 14:08:57 UTC. «Cloudflare’s smart CAPTCHA alternative»; integración sin tráfico de Cloudflare; los desafíos de JavaScript no interactivos y la descripción de la prueba de trabajo y la prueba de espacio; la afirmación de que Turnstile no accede a lo que se escribe en los formularios, ni lo guarda ni lo transmite. ↩↩↩↩↩↩

  22. Cloudflare, “Turnstile Privacy Addendum”, última actualización el 18 de junio de 2025, consultado el 3 de noviembre de 2026, 14:08:57 UTC. Las señales que procesa Turnstile, su propósito declarado y el papel de Cloudflare como responsable del tratamiento para mejorar la detección de bots, todo citado. ↩↩↩

  23. Captain’s Log del autor, 2 de noviembre de 2026: mi pregunta a la sesión de agente que opera mi infraestructura, si era ella la que enviaba los correos, los tres flujos de correo que comparten una dirección de remitente, los asuntos «Contact Form:» de los correos con las cargas del escáner, y mi nota de que los envíos del formulario del escáner «ran about nine a minute» (iban a unos nueve por minuto). ↩↩↩↩↩

  24. Lectura del código por el autor. El controlador de contacto y la plantilla del formulario de este sitio tal como estaban desplegados el 30 de octubre y el 2 de noviembre: un formulario HTMX (hx-post a la ruta del formulario) con el token CSRF insertado; el decorador del limitador antes del controlador; la verificación CSRF, que lanza un 403, antes del envío; el envío a través de Resend con el asunto «Contact Form:» y el nombre del remitente; el fragmento de éxito en un envío aceptado; y dos rutas de fallo, una configuración de correo ausente y un envío fallido o sin id, que devuelven el fragmento de error con el estado predeterminado de FastAPI, 200. Sin campo honeypot. Leído del código fuente el 3 de noviembre de 2026. ↩↩↩↩↩↩↩↩↩

  25. Lectura del código e historial del repositorio del sitio del estudio, por el autor, tal como estaba desplegado antes del 3 de noviembre de 2026: la rama honeypot del controlador de contacto (un campo oculto website no vacío responde con el fragmento de éxito y no guarda ni envía nada; es la primera verificación del controlador, antes de la verificación de dirección, la inserción y el notificador), presente desde el commit que construyó el formulario el 5 de junio de 2026; el tabindex="-1" y el aria-hidden del campo oculto; sin token CSRF; el notificador de mejor esfuerzo, añadido el 21 de septiembre de 2026 a las 17:46:47 UTC en un commit cuyo mensaje dice «a paying customer’s message sat unread in D1 for a week»; y un limitador de tasa en memoria por proceso para clientes anónimos, con clave por dirección del cliente y clasificación del cliente (el nivel de bot que el limitador asigna a partir del agente de usuario y la dirección), sobre todas las rutas salvo las comprobaciones de salud y los archivos estáticos. Leído del repositorio el 3 de noviembre de 2026. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩

  26. Captain’s Log del autor, 30 de octubre de 2026, escrito a las 11:58 PDT: mi reporte de la avalancha a las 11:46 PDT, el bloqueo manual y la regla de límite de tasa de las 11:48 a las 11:49 PDT, y mi prueba a las 11:52 PDT, citada. Sus conteos se tomaron mientras Cloudflare todavía ingería los últimos minutos de la avalancha, y este post usa en su lugar las consultas del 3 de noviembre. ↩↩↩

  27. Cloudflare, “GraphQL API - Limits”, documentación de la GraphQL Analytics API, consultada el 3 de noviembre de 2026, 14:08:57 UTC. Los conjuntos de datos adaptativos «retain at least 31 days of data on every plan» (conservan al menos 31 días de datos en todos los planes). ↩↩

  28. Resend, “Event Types”, documentación de webhooks de Resend, consultada el 3 de noviembre de 2026, 14:14:47 UTC. email.sent «Occurs whenever the API request was successful. Resend will attempt to deliver the message to the recipient’s mail server.»; email.delivered para la entrega exitosa. ↩↩↩

  29. Datos del autor. Análisis de solicitudes de Cloudflare para todas las demás direcciones que enviaron el formulario de contacto de este sitio en la consulta de ocho días, excluidos los dos escáneres y mi dirección de prueba, consultados el 3 de noviembre de 2026: tres direcciones (Corea del Sur, Japón, India), cada una con exactamente dos 200 en un solo día, el 27, el 28 y el 29 de octubre. ↩

  30. RIPE NCC, RIPEstat network-info, whois y as-overview para la dirección del 30 de octubre y su AS, consultados el 3 de noviembre de 2026, 14:10:34 y 14:11:34 UTC: AS34534, titular «BULLIONET FBW NETWORKS SAS», una asignación registrada con país FR. ↩

  31. Datos del autor. Análisis de solicitudes de Cloudflare para la dirección del 30 de octubre agrupados por ruta, método y estado, consultados el 3 de noviembre de 2026; la consulta devolvió su límite de 3.000 filas, que cubre 74.125 de 76.429 solicitudes, así que los conteos de rutas son límites inferiores: al menos 2.881 rutas distintas y al menos 2.680 solicitudes con una carga de inyección SQL en la ruta en al menos 810 rutas distintas; 320 POST a la ruta de compartir de la aplicación de pintura, todos rechazados (422 o 400), y ninguno de otra dirección en ocho días; las variantes con carga de la ruta de compartir citadas en la sección 2, respondidas con 404. ↩↩↩↩

  32. Datos del autor. Análisis de solicitudes de Cloudflare para la dirección del 30 de octubre agrupados por agente de usuario, consultados el 3 de noviembre de 2026; la consulta devolvió su límite de 500 filas, que cubre 76.095 de 76.429 solicitudes, así que cada conteo aquí es un límite inferior: al menos 69.322 solicitudes con una cadena simple de Chrome 141 para Windows, al menos 2.755 que decían ser Googlebot y al menos 3.154 con una carga de inyección SQL en al menos 396 cadenas distintas. ↩↩↩↩↩↩

  33. PortSwigger Web Security Academy, “SQL injection cheat sheet”, consultada el 3 de noviembre de 2026, 14:10:34 UTC. Retardos de tiempo para Oracle (dbms_pipe.receive_message), Microsoft (WAITFOR DELAY), PostgreSQL (pg_sleep) y MySQL (SLEEP); la forma condicional IF(condition, SLEEP(10), 'a'); SELECT @@version para Microsoft y MySQL; el FROM dual de Oracle. ↩↩↩↩↩

  34. Proyecto sqlmap, “time_blind.xml”, las definiciones de cargas ciegas basadas en tiempo de la herramienta, consultadas el 3 de noviembre de 2026, 14:12:27 UTC. Contiene cargas SLEEP, PG_SLEEP, WAITFOR DELAY y DBMS_PIPE.RECEIVE_MESSAGE. Citado por las familias, no como la herramienta que usó alguno de los escáneres. ↩

  35. PortSwigger Web Security Academy, “Blind SQL injection”, consultada el 3 de noviembre de 2026, 14:10:34 UTC. Por qué un retardo en una consulta retrasa la respuesta HTTP, citado. ↩

  36. OWASP Automated Threats to Web Applications, “OAT-014 Vulnerability Scanning”, consultado el 3 de noviembre de 2026, 14:13:41 UTC. El resumen, citado. ↩

  37. Datos del autor, anexo del registro. Análisis de solicitudes de Cloudflare para los POST de los dos escáneres al formulario agrupados por estado en el borde y estado del origen, consultados el 3 de noviembre de 2026: el 30 de octubre, 395 de los 830 429 llevan un estado de origen 429 y los otros 435 ninguno registrado; el 2 de noviembre, 16 de los 31 429 llevan un 429 de origen y 15 ninguno registrado. Ningún 429 lleva un estado solo del borde donde se registró el del origen. ↩↩

  38. Prueba del autor, 3 de noviembre de 2026: una aplicación FastAPI de diez líneas ejecutada con el entorno del propio sitio (FastAPI 0.128.0), con un decorador al estilo del limitador en el endpoint. Un POST sin un campo obligatorio devolvió 422 sin que se llamara nunca al envoltorio; un POST completo llegó al envoltorio. ↩

  39. Datos del autor. Eventos de firewall y análisis de solicitudes de Cloudflare para mi propia dirección de prueba el 30 de octubre de 2026, consultados el 3 de noviembre: tres eventos de la regla de límite de tasa, el primero a las 18:52:38 UTC, con un agente de usuario de navegador, y 24 desafíos gestionados de la regla de desafío por agente de usuario para mis envíos con curl. Excluidos de todos los conteos del incidente. ↩↩↩↩↩

  40. RIPE NCC, RIPEstat network-info, whois y as-overview para la dirección del 2 de noviembre y su AS, consultados el 3 de noviembre de 2026, 14:10:34 y 14:11:34 UTC: AS24875, titular «NOVOSERVE-AS NovoServe B.V.», una ruta registrada a nombre de NovoServe B.V., país NL. La descripción de la subasignación nombra a una persona particular y no se repite aquí. ↩

  41. Datos del autor. Análisis de solicitudes de Cloudflare para la dirección del 2 de noviembre agrupados por agente de usuario, consultados el 3 de noviembre de 2026, completos (73 cadenas que cubren las 10.909 solicitudes): 9.725 solicitudes con una cadena simple de Chrome 141 para Windows, 942 que decían ser Googlebot, 54 cargas de inyección SQL distintas en 149 solicitudes, 3 cadenas para romper comillas en 78, 12 tokens marcadores @@ en 12, y otras 2 corrientes. ↩↩↩↩↩↩

  42. Datos del autor. Las tablas de agentes de usuario de ambos días, construidas por el script del registro el 3 de noviembre de 2026: la cadena simple de Chrome 141 para Windows y la cadena de Googlebot son idénticas byte a byte el 30 de octubre y el 2 de noviembre. ↩

  43. Repetición del autor, 3 de noviembre de 2026, de los propios detectores de mi monitor sobre datos completos de Cloudflare para las 18:58:23 a 19:03:23 UTC del 2 de noviembre, la ventana de la ejecución que arrancó a las 11:02:46 PST, ejecutada a las 15:55:04 UTC y guardada como registro de repetición con los comandos exactos y la salida completa de los detectores; cinco filas del anexo del registro se derivan de ese registro por script: 2.143 solicitudes de la dirección en el hallazgo de avalancha de solicitudes, 1.055 solicitudes rechazadas en el hallazgo de escáner, y un hallazgo de avalancha sobre el formulario a partir de 39 envíos con 23 rechazados, con severidad alta, que va al resumen diario y ni envía correo ni bloquea. Los 1.914 de la ejecución real, en una ventana que terminaba en el momento en que la ejecución consultaba, son lo que registró el log de mi propio monitor, y la ejecución real no levantó ningún hallazgo de avalancha sobre el formulario. El cambio de ventana hecho ese mismo día: cada ventana real termina ahora 90 segundos antes de la ejecución y empieza donde terminó la anterior, con una mirada atrás simple de cinco minutos cuando falta el registro anterior o es antiguo. ↩↩↩

  44. Datos del autor. Análisis de solicitudes de Cloudflare para la dirección del 2 de noviembre agrupados por ruta, consultados el 3 de noviembre de 2026, completos (2.318 filas que cubren las 10.909 solicitudes): 2.173 rutas distintas, y 778 solicitudes en 542 rutas distintas con una carga de inyección SQL. ↩↩

  45. OWASP Web Security Testing Guide v4.2, “Testing for SQL Injection”, consultada el 3 de noviembre de 2026, 14:12:12 UTC. Las técnicas basadas en errores y en retardos de tiempo, citadas. ↩

  46. Registro del autor del tercer cambio del 3 de noviembre de 2026, hecho tras una revisión del borrador de este post: a las 16:12 UTC, los dominios permitidos de ambos widgets fijados en el sitio y su host www, sin localhost; las listas predeterminadas de nombres de host de ambos verificadores fijadas en esos mismos dos nombres de host, con el desarrollo local fijando su propia lista; las pruebas de este sitio verifican que localhost se rechaza por defecto (11 aprobadas); el sitio del estudio volvió a desplegarse y, ejecutado en local contra la clave de prueba de Cloudflare que siempre aprueba, un token ficticio siguió llegando a la validación con el nombre de host de relleno de esa clave en la lista y fue rechazado sin él. A las 16:13 UTC se desactivaron las dos reglas de desafío gestionado basadas en la puntuación de amenaza, una en cada zona, con el motivo en sus descripciones; los desafíos por agente de usuario siguen. Verificaciones en vivo tras este cambio: el sitio del estudio a las 16:15 UTC (widget, envíos sin token y con token basura 403, honeypot 200), este sitio a las 16:18 UTC (widget, envío sin token 403); ninguna ejercita la regla del nombre de host. ↩↩↩

  47. Cloudflare, “Custom rules”, documentación del WAF de Cloudflare, consultada el 3 de noviembre de 2026, 14:10:34 UTC. Qué hacen las reglas personalizadas, citado, y las cinco reglas del plan Free con todas las acciones salvo Log. ↩

  48. Cloudflare, “Rate limiting rules”, documentación del WAF de Cloudflare, consultada el 3 de noviembre de 2026, 14:08:57 UTC. Los parámetros de la regla; la advertencia de precisión y el retraso en la actualización de contadores, citados; la tabla de disponibilidad, que da al plan Free un periodo de conteo de 10 segundos, un tiempo de mitigación de 10 segundos, conteo por IP y una regla. ↩↩↩↩↩↩

  49. Ned Batchelder, “Stopping spambots with hashes and honeypots”, creado el 21 de enero de 2007, consultado el 3 de noviembre de 2026, 14:10:34 UTC. La definición de honeypot, la razón por la que los bots caen en los campos invisibles, la taxonomía de reproducción y llenado de formularios, y sus dos frases sobre bots que vencen las medidas y sobre volver a los CAPTCHA, todo citado. La página describe la técnica como la forma en que protege su sitio; no afirma haberla inventado. ↩↩↩↩↩↩

  50. OWASP Cheat Sheet Series, “Credential Stuffing Prevention Cheat Sheet”, consultada el 3 de noviembre de 2026, 14:10:34 UTC. No depender de un único límite de volumen predecible; un volumen bajo por dirección que vence las listas de bloqueo y el límite de tasa; herramientas que envían directamente sin ejecutar JavaScript, todo citado. ↩↩↩

  51. Cloudflare, “Cloudflare HTTP headers”, documentación de Cloudflare Fundamentals, consultada el 3 de noviembre de 2026, 14:14:20 UTC. Los códigos especiales de la cabecera CF-IPCountry, incluido «T1 - Used for clients using the Tor network.» ↩

  52. Cloudflare, “IP Access rules parameters”, documentación del WAF de Cloudflare, consultada el 3 de noviembre de 2026, 14:14:20 UTC. «T1: Tor exit nodes (country name: Tor)». ↩

  53. The Tor Project, “Bulk Tor Exit Exporter”, check.torproject.org, consultado el 3 de noviembre de 2026, 14:10:34 UTC. La descripción de la lista, citada, y la referencia a CollecTor para datos pasados; la lista en sí, descargada de https://check.torproject.org/torbulkexitlist al mismo tiempo, tenía 1.239 direcciones. ↩↩

  54. Datos del autor, anexo del registro. Cada dirección distinta que envió el formulario del sitio del estudio en la consulta de ocho días, dividida por el código de país de Cloudflare, comparada por script con la lista masiva de salidas del Tor Project descargada a las 14:10:34 UTC del 3 de noviembre de 2026 (1.239 direcciones): 23 de las 23 direcciones con código T1 en la lista, y 0 de las otras 12. Solo la lista del 3 de noviembre; los días de los eventos no se comprobaron contra el archivo. ↩

  55. The Tor Project, “Sources”, Tor Metrics, consultado el 3 de noviembre de 2026, 14:10:34 UTC. Listas de salidas de TorDNSEL «containing the IP addresses of relays that it found when exiting through them» (que contienen las direcciones IP de los relés que encontró al salir a través de ellos), y su archivo. ↩↩

  56. OWASP Automated Threats to Web Applications, “OAT-017 Spamming”, consultado el 3 de noviembre de 2026, 14:10:34 UTC. El resumen, los otros nombres, incluido el spam de formularios, y la exclusión del abuso de formulario a correo dirigido a destinatarios no previstos, citados. ↩↩

  57. Análisis del autor. Los 108 mensajes guardados del sitio del estudio clasificados con reglas fijas, la primera coincidencia gana (coincidencia por nombre de remitente para la pregunta del precio y los cebos de premios, texto del mensaje para el resto, una fila colocada a mano como consulta real), 3 de noviembre de 2026: los conteos de la tabla de la sección 5; los 25 idiomas de la pregunta del precio en toda la tabla y 9 en octubre, según mi lectura de cada frase fija, con croata (o bosnio) e indonesio (o malayo) como casos ajustados; cada fila de la ventana posterior a la consulta clasificada como spam. ↩↩↩↩↩

  58. Cloudflare, “Turnstile widgets”, documentación de Turnstile, consultada el 3 de noviembre de 2026, 14:08:57 UTC. Los modos gestionado, no interactivo e invisible y lo que ve un visitante en cada uno, citado para el gestionado; la condición de política de privacidad del modo invisible; el origen challenges.cloudflare.com. ↩↩↩

  59. Cloudflare, “Cloudflare Turnstile plans”, documentación de Turnstile, consultada el 3 de noviembre de 2026, 14:08:57 UTC. Free y Enterprise; hasta 20 widgets; desafíos ilimitados; uso sin otros servicios de Cloudflare, citado. ↩

  60. Cloudflare, “Validate the token”, documentación de validación del lado del servidor de Turnstile, consultada el 3 de noviembre de 2026, 14:08:57 UTC. La obligación de llamar a siteverify; los tokens falsificados; el endpoint y sus campos secret, response y remoteip; la validez de 300 segundos y el uso único; el máximo de 2.048 caracteres; la verificación de acción y nombre de host; la tabla de códigos de error y sus dos descripciones del vencimiento; el límite de tasa contra las avalanchas de validaciones. Todos los pasajes citados son de esta página. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩

  61. Cloudflare, “Cloudflare Turnstile - Easy CAPTCHA Alternative”, página de producto, consultada el 3 de noviembre de 2026, 14:08:57 UTC. «Turnstile never harvests data for ad retargeting.» ↩

  62. Cloudflare, “Content Security Policy”, documentación de Turnstile, consultada el 3 de noviembre de 2026, 14:08:57 UTC. script-src y frame-src para https://challenges.cloudflare.com; el enfoque basado en nonce; el requisito de connect-src 'self' para la preautorización, citado. ↩↩↩

  63. Cloudflare, “Interstitial Challenge Pages”, documentación de Cloudflare Challenges, consultada el 3 de noviembre de 2026, 14:08:57 UTC. Cómo una página de desafío retiene la solicitud; los desafíos gestionados recomendados para la mayoría de las reglas del WAF; la limitación con AJAX y XHR (fetch) y la recomendación de la preautorización de Turnstile, ambas citadas. ↩↩↩↩↩↩

  64. Cloudflare, “Detect a Challenge Page response”, documentación de Cloudflare Challenges, consultada el 3 de noviembre de 2026, 14:10:34 UTC. La cabecera cf-mitigated con el valor challenge y el content-type text/html de todo desafío, citados. ↩

  65. Cloudflare, “Clearance”, documentación de Cloudflare Challenges, consultada el 3 de noviembre de 2026, 14:10:34 UTC. La cookie cf_clearance y la preautorización, citadas: la cookie se emite además del token de un solo uso predeterminado, Siteverify sigue siendo obligatorio, y una cookie permite saltarse los desafíos hasta el nivel de autorización fijado en el widget. ↩↩↩

  66. Registro del autor del segundo cambio del 3 de noviembre de 2026, hecho esa misma mañana: el sitio del estudio desplegado a las 15:19 UTC y verificado en vivo a las 15:22 UTC (widget, script, clave del sitio y el origen de desafíos en el frame-src en vivo; un envío sin token y un envío con token basura rechazados con 403; un envío al honeypot respondido con 200); este sitio compilado desde las 15:20 UTC, con la caché purgada, y verificado en vivo a las 15:25 UTC (las mismas verificaciones de página, un envío sin token rechazado con 403, y luego un envío con un token CSRF válido de la página en vivo y sin token de Turnstile rechazado con 403 de nuevo, lo que muestra que el formulario falla cerrado tras el despliegue pero no puede ejercitar la verificación del nombre de host); ambos verificadores exigen ahora que el nombre de host de la respuesta de Cloudflare sea uno de los propios del sitio (una respuesta sin él se rechaza) y, cuando Cloudflare devuelve una acción, la del propio widget; las once pruebas de este sitio pasan, y la nueva acepta el nombre de host del sitio y rechaza otro nombre de host, otra acción y un nombre de host ausente; en el sitio del estudio, ejecutado en local contra la clave de prueba de Cloudflare que siempre aprueba, que responde con un nombre de host de relleno y sin acción, un token ficticio llegó a la validación con ese nombre de host en la lista y fue rechazado sin él. ↩↩↩↩

  67. Cloudflare, “Test your Turnstile implementation”, documentación de Turnstile, consultada el 3 de noviembre de 2026, 14:08:57 UTC. Las claves de prueba que siempre aprueban y siempre rechazan y el token ficticio; los secretos de producción rechazan los tokens ficticios; las baterías de pruebas automatizadas detectadas como bots, citado. ↩↩↩↩↩

  68. Registro del autor del cuarto cambio del 3 de noviembre de 2026, hecho tras una segunda revisión del borrador de este post: el manejador de la primera versión para el evento before-swap de HTMX, que deja pasar un fragmento 403 frente al comportamiento predeterminado de HTMX de no insertar una respuesta 4xx, estaba adjunto al formulario de contacto, mientras que HTMX dispara ese evento en el destino de la inserción, el hermano del formulario, así que nunca se mostraba un fragmento 403; la revisión lo encontró leyendo el HTMX empaquetado y la plantilla. El arreglo es un listener en el body de la página limitado al id del destino de la respuesta, y la prueba de la página de inicio verifica que se renderiza (11 pruebas aprobadas). Después de verificar el despliegue a las 16:18 UTC, una verificación en el navegador a las 16:21 UTC en el Chromium de Playwright reemplazó el token de 730 caracteres del widget por una cadena basura, llenó los campos obligatorios con valores de relleno y envió el formulario a través de HTMX; el destino de la respuesta mostró el fragmento de error, «error: Verification failed. Reload the page and try again.», y ningún fragmento de éxito, y no se envió nada. No hecho: el envío completo de una persona. ↩↩

  69. Cloudflare, “Embed the widget”, documentación de Turnstile, consultada el 3 de noviembre de 2026, 14:08:57 UTC. El script api.js, el contenedor del widget, el campo oculto cf-turnstile-response creado dentro de un formulario, y turnstile.reset(widgetId) para widgets cuyo tiempo se agotó o vencieron, citado. ↩↩↩

  70. Registro del autor de las verificaciones en vivo del 3 de noviembre de 2026, cada una después de que el despliegue de su propio sitio hubiera terminado con éxito, y después de la purga de caché: a las 06:35:24 y 06:35:54 PST, un div del widget, una etiqueta de script y la clave del sitio en cada página de inicio, el origen de desafíos en cada frame-src en vivo, y en este sitio un envío con todos los campos presentes y un token CSRF basura rechazado con 403 por la verificación CSRF; a las 14:43 UTC, en este sitio, un envío con un token CSRF válido de 108 caracteres de la página en vivo y sin token de Turnstile rechazado con 403 y «Verification failed. Reload the page and try again.», y el mismo envío con un token de Turnstile basura rechazado con 403; en el sitio del estudio, un envío sin token y un envío con token basura rechazados con 403 y un envío al honeypot respondido con 200 sin guardar ni enviar nada; en el Chromium de Playwright a las 14:39:50 y 14:40:04 UTC, el widget renderizado (602 por 73 y 557 por 71 píxeles), el objeto de la API de Turnstile cargado y el campo oculto de respuesta con un token de 730 caracteres en ambas páginas en menos de seis segundos. No hecho: el envío completo de una persona. ↩↩↩

  71. Cloudflare, “Rate limiting parameters”, documentación del WAF de Cloudflare, consultada el 3 de noviembre de 2026, 14:08:57 UTC. Los nombres de los parámetros; en los planes Free, Pro y Business una acción de desafío siempre limita el caudal, parafraseado; un desafío superado reinicia el contador, citado. ↩

  72. OWASP Cheat Sheet Series, “Authentication Cheat Sheet”, consultada el 3 de noviembre de 2026, 14:10:34 UTC. El CAPTCHA como control de defensa en profundidad, citado. ↩

  73. Resend, “Retrieve Sent Email”, referencia de la API de Resend, consultada el 3 de noviembre de 2026, 14:14:47 UTC. El campo last_event de un correo consultado, por ejemplo «delivered». ↩↩

  74. Resend, “View and manage emails”, documentación de Resend, consultada el 3 de noviembre de 2026, 14:15:12 UTC. La lista de eventos de correo, incluidos «sent» y «delivered», citados, y «queued» solo para Broadcasts y Batches. ↩

  75. Cloudflare, “Onion Routing and Tor support”, documentación de Cloudflare Network, consultada el 3 de noviembre de 2026, 14:08:57 UTC. Onion Routing en todos los planes y la distinción entre usuarios humanos de Tor y bots, citada. ↩

  76. Cloudflare, “Challenges”, documentación de Cloudflare Challenges, consultada el 3 de noviembre de 2026, 14:08:57 UTC. «Cloudflare does not use CAPTCHA puzzles or visual tests like selecting objects or typing distorted characters.» ↩

  77. OWASP Community, “Blind SQL Injection”, consultado el 3 de noviembre de 2026, 14:10:34 UTC. La inyección ciega basada en tiempo que depende de que la base de datos se detenga, con WAIT FOR DELAY y pg_sleep() como ejemplos. ↩

Artículos relacionados

Fatiga de alertas: 82 correos en un día de mi monitor de seguridad

Un resumen diario bajó mi monitor de seguridad de 82 correos en un día a 19 al siguiente: la política, un escáner bloque…

66 min de lectura

La bomba fork nos salvó

El atacante de LiteLLM cometió un solo error de implementación. Ese error fue la única razón por la que 47.000 instalaci…

9 min de lectura

Sobre lo que me niego a escribir

La voz de un clúster de blog viene de lo que se niega a publicar, no de lo que envía: negativas categóricas, de patrón e…

13 min de lectura