Bitácora
Cómo frenamos el spam en formularios y webhooks sin usar captcha
Límites por dirección y por ruta, campo trampa, tiempo mínimo, validación estricta, webhooks con secreto y alertas de error por correo. Lo que hicimos de verdad, con plantilla copiable.
Redacción Mandato IA · Publicado el · Actualizado el · 14 min de lectura
En este artículo
- El problema, en concreto
- Capa 1: límites por dirección y un tope global
- Capa 2: campo trampa y tiempo mínimo
- Capa 3: validar todo lo que entra, en el servidor
- Capa 4: webhooks que solo responden a quien tiene la clave
- Capa 5: enterarse cuando algo falla
- La plantilla: el limitador y el campo trampa
- Dos ejemplos para aplicarlo
- Errores comunes
- Lo que esto no resuelve
Un formulario abierto en internet es una puerta sin portero. Lo usan tus clientes, pero también lo encuentran programas automáticos que mandan basura, prueban direcciones de correo o intentan usar tu sitio para enviar mensajes a terceros. Con los webhooks pasa algo parecido: son direcciones que, si alguien las descubre, cualquiera puede llamar.
La respuesta habitual es un captcha, esa prueba de «no soy un robot». Nosotros no usamos ninguno. No porque sean malos, sino porque en un formulario donde una persona cuenta un problema legal cada paso de más pesa, y porque un captcha suele ser un script de un tercero que tendríamos que abrirle a nuestra política de seguridad de contenido. Preferimos varias capas pequeñas que el visitante no ve. Este artículo cuenta cuáles son, con los números que usamos y los errores que encontramos al revisarlas.
El problema, en concreto
Nuestro sitio tiene varias rutas en el servidor que reciben datos: una que envía un documento por correo a quien lo pide, otra que registra contactos, otra que recibe reseñas, una que alimenta una demostración en vivo y otra que recibe los casos que los clientes de un despacho envían desde la página de ese despacho. Detrás de varias de ellas hay un flujo de n8n, un modelo de IA que cuesta dinero por uso, o un servicio de correo con topes diarios.
OWASP tiene un nombre para este riesgo en su lista de riesgos de APIs de 2023: consumo de recursos sin restricción. No se trata solo de que el servidor se caiga; también de que la cuenta del proveedor de IA o de correo suba sin control. Sus recomendaciones son tres: limitar la frecuencia de uso, fijar tamaños máximos a todo lo que entra y poner topes de gasto en los servicios de terceros, o al menos alertas de facturación.
Una revisión de seguridad que hicimos el 30 de septiembre de 2026 nos mostró que estábamos flojos en las tres. La más grave: la ruta que envía un documento por correo aceptaba cualquier dirección y no tenía tope. Alguien podía usarla para mandar adjuntos a cientos de personas desde nuestro dominio.
Capa 1: límites por dirección y un tope global
Cada ruta tiene dos contadores. Uno por dirección IP del visitante, que frena a un programa concreto. Otro global para toda la ruta, que frena un ataque repartido entre muchas direcciones y también cubre el caso en que la dirección no se pueda leer. Cuando se supera cualquiera de los dos, la ruta responde con el código 429, que según MDN significa «demasiadas solicitudes» y es la forma estándar de decir «espera un rato».
| Ruta | Por dirección | Global | Ventana |
|---|---|---|---|
| Envío de un documento por correo | 3 | — | 1 hora |
| Registro de contacto | 5 | — | 1 hora |
| Reseña con código de compra | 10 | — | 1 hora |
| Reseña por enlace abierto | 4 | 60 | 1 hora |
| Demostración en vivo | 8 | 240 | 1 hora |
| Caso desde la página de un despacho | 5 | 400 (y 40 por despacho) | 1 hora |
Los contadores viven en la memoria del proceso. Es la solución más simple posible y alcanza porque el sitio corre en un solo contenedor. Tiene dos limitaciones que conviene conocer: cada vez que se publica una versión nueva, los contadores vuelven a cero; y si algún día el sitio corriera en varios contenedores, cada uno tendría su propia cuenta y el límite real se multiplicaría. Seguiría protegiendo, pero menos. Para algo más serio, los contadores irían a una base compartida, como Redis.
El error de leer la dirección equivocada
Nuestro sitio está detrás de un proxy que recibe el tráfico y lo pasa a la aplicación. El proxy anota la dirección real del visitante en cabeceras, como X-Real-IP y X-Forwarded-For. En una auditoría del 1 de octubre descubrimos que leíamos el primer valor de X-Forwarded-For, y ese valor lo puede escribir el propio visitante. Mandando una dirección privada inventada, alguien podía saltarse el límite por IP.
MDN lo advierte sin rodeos: cualquier uso de esa cabecera con fines de seguridad, como limitar la frecuencia, debe usar solo las direcciones agregadas por un proxy de confianza, y los valores de la izquierda pueden ser falsos. Ahora leemos primero X-Real-IP, que escribe nuestro proxy, y si no está, el último valor de la lista, que es el que agregó el proxy. Y si la dirección resultante es privada (la red interna de Docker), no la usamos para contar: todos los visitantes compartirían la misma cuenta. En ese caso solo cuenta el tope global.
Capa 2: campo trampa y tiempo mínimo
El campo trampa es un campo de formulario que una persona no ve ni puede alcanzar con el teclado, pero que un programa que llena formularios a ciegas completa. Si llega con algo escrito, sabemos que no fue una persona. Le ponemos un nombre creíble (algo como «pagina_web»), lo sacamos fuera de la pantalla con estilos, lo marcamos como oculto para los lectores de pantalla, lo sacamos del orden de tabulación y le desactivamos el autocompletado, para que el navegador de una persona real no lo llene solo.
En el formulario de casos sumamos un segundo filtro: el tiempo. El formulario anota cuándo se abrió, y si se envía en menos de tres segundos, lo tratamos como robot. Nadie escribe un relato en tres segundos.
Un detalle importante: a un robot detectado le respondemos «listo, recibido», con el mismo mensaje de éxito que a una persona. No guardamos nada ni avisamos a nadie, pero tampoco le enseñamos qué lo delató. Un mensaje de error le diría que pruebe otra cosa.
Capa 3: validar todo lo que entra, en el servidor
OWASP recomienda validar con listas de lo permitido, no con listas de lo prohibido, y hacerlo siempre en el servidor aunque el navegador ya haya revisado los mismos campos. La validación del navegador es comodidad para la persona; la del servidor es la que protege. Esto es lo que hacemos en cada ruta:
- Tamaño máximo del pedido completo (en la ruta de casos, 8.000 caracteres) y de cada campo (el relato, 2.000, que además es el límite de un bloque de texto en Notion).
- Tipo de contenido obligatorio: si no llega como JSON, se rechaza.
- Correo con una lista blanca estricta de caracteres. Antes aceptábamos signos como el igual o el signo de pesos, y ese correo terminaba dentro de un flujo de n8n, donde un valor que empieza con «=» se evalúa como expresión.
- Mínimos razonables: un nombre de al menos dos letras, un relato de al menos quince caracteres, un teléfono de entre 7 y 15 dígitos.
- En las reseñas abiertas, ningún enlace: el texto con una dirección web se rechaza.
- La materia del caso solo puede ser una de la lista; cualquier otro valor se trata como «no sabe».
Del lado de n8n agregamos controles parecidos dentro de los flujos: el pedido de generar un escrito con IA exige un identificador de caso con el formato exacto de un identificador de Notion y tiene un tope por hora; la entrada de casos también tiene su tope y descarta el mismo caso (mismo correo y mismo relato) si se repite en 24 horas. Y los dos nodos de IA llevan una instrucción contra la inyección: el texto del cliente es un dato que se analiza, no una orden que se obedece.
Capa 4: webhooks que solo responden a quien tiene la clave
Un webhook es una dirección pública. Esconderla ayuda, pero no es seguridad: las direcciones se filtran en registros, capturas y correos. Todos nuestros webhooks que hacen algo importante exigen una clave secreta en una cabecera. La plataforma de pagos, por ejemplo, manda un token en cada aviso de compra; nuestras propias piezas internas se llaman entre sí con una clave compartida. Si la clave no coincide, la respuesta es 401 o 403 y no se hace nada más.
La comparación de la clave la hacemos en tiempo constante, con la función que trae Node para eso, en lugar de comparar dos textos con un igual. Una comparación normal se detiene en el primer carácter distinto, y en teoría el tiempo de respuesta podría dar pistas sobre cuántos caracteres acertó alguien. Es un detalle, pero cuesta una línea.
Dos reglas más que aprendimos con los avisos de compra. Primero, responder con éxito a todo lo que no hay que reintentar (un producto que no nos corresponde, un evento que ignoramos) y con error solo cuando falló algo nuestro, como la base de datos, para que la plataforma vuelva a mandarlo. Segundo, que cada aviso se procese una sola vez: guardamos el número de transacción como único, así que si la plataforma reintenta, el segundo intento falla a propósito y no sale un segundo correo de bienvenida.
El anti-spam que frenaba a todo el mundo
Este error es el mejor ejemplo de por qué hay que revisar con datos. El flujo de n8n de la demostración tenía su propio anti-spam: una prueba cada 30 segundos por dirección IP. Parecía razonable. Pero el sitio le habla a n8n desde dentro del mismo servidor, así que n8n veía la misma dirección interna en todas las llamadas. En la práctica, el límite era una sola demostración cada 30 segundos para todo el mundo.
En n8n, un contador así se suele guardar en los datos estáticos del flujo (así estaba en la primera versión de nuestra plantilla de despachos), una pequeña memoria que n8n conserva entre ejecuciones desde un nodo de código. Sirve para esto, con una condición que la documentación aclara: solo se guarda en ejecuciones reales del flujo publicado, no en las pruebas desde el editor. Si pruebas un límite así a mano, no vas a ver cómo se comporta de verdad.
El arreglo fue que el sitio le pase a n8n la dirección del visitante en una cabecera propia, que el flujo cuente por esa dirección, que descarte todo pedido que no venga de la red interna (es decir, que no haya pasado por el sitio) y que tenga un tope global de 60 cada 10 minutos. En los flujos de los despachos encontramos el mismo patrón al revés: el límite por IP nunca recibía la dirección, y dos clientes que escribían seguidos compartían el mismo límite. Lo reemplazamos por el descarte de casos repetidos.
Capa 5: enterarse cuando algo falla
Las defensas también fallan, y un flujo roto en silencio puede ser peor que el spam. En n8n armamos un flujo de errores: empieza con el nodo Error Trigger y se ejecuta cuando falla cualquier flujo que lo tenga asignado. Nos manda un correo con el flujo y el nodo que fallaron, con un máximo de uno cada 15 minutos por flujo, para que una falla repetida no inunde la bandeja. Lo asignamos a todos los flujos y el proceso de alta lo asigna solo a cada flujo nuevo.
Una advertencia de nuestra experiencia: la documentación dice que el flujo de errores no necesita estar publicado, pero en nuestra instalación solo corrió cuando lo dejamos activo. Y no se puede probar a mano: el Error Trigger solo responde a fallas de ejecuciones automáticas. Lo probamos provocando un error real.
La plantilla: el limitador y el campo trampa
Este es el esqueleto de lo que usamos, simplificado y sin datos propios. Está escrito para una ruta de servidor en JavaScript, pero la lógica sirve en cualquier lenguaje o en un nodo de código de n8n.
// 1. Limitador en memoria: un contador por dirección y uno global por ruta
const cuentas = new Map();
function contar(clave, maximo, ventanaMs) {
const ahora = Date.now();
const c = cuentas.get(clave);
if (!c || ahora - c.inicio >= ventanaMs) {
cuentas.set(clave, { inicio: ahora, usados: 1 });
return true;
}
if (c.usados >= maximo) return false;
c.usados += 1;
return true;
}
function ipDelVisitante(req) {
// Primero la que escribe TU proxy; si no, el ÚLTIMO valor de la lista.
const ip = req.headers['x-real-ip']
|| (req.headers['x-forwarded-for'] || '').split(',').pop().trim();
return ip && !esPrivada(ip) ? ip : null; // privada = no sirve para contar
}
function dentroDelLimite(req, ruta, porIp, global, ventanaMs) {
const ip = ipDelVisitante(req);
if (ip && !contar(ruta + ':ip:' + ip, porIp, ventanaMs)) return false;
return contar(ruta + ':global', global, ventanaMs);
}
// 2. En la ruta
if (!dentroDelLimite(req, 'contacto', 5, 200, 3600000)) {
return responder(429, 'Demasiados intentos. Prueba de nuevo en un rato.');
}
const datos = leerJsonConTope(req, 8000); // rechaza lo que pase del tope
if (datos.pagina_web || Date.now() - Number(datos.t0) < 3000) {
return responder(200, 'Recibido'); // robot: éxito falso, no se guarda nada
}
if (!correoValido(datos.correo) || recortar(datos.relato, 2000).length < 15) {
return responder(422, 'Revisa los datos');
}
// ...recién aquí se guarda o se reenvía<!-- 3. El campo trampa en el formulario -->
<div class="trampa" aria-hidden="true">
<label>No llenar este campo
<input name="pagina_web" tabindex="-1" autocomplete="off">
</label>
</div>
<input type="hidden" name="t0" value="(hora en que se abrió el formulario)">
/* Fuera de la pantalla, no display:none */
.trampa { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }// 4. Webhook con secreto (pseudocódigo)
si la cabecera 'x-clave' no coincide con la variable de entorno CLAVE_WEBHOOK
(comparando en tiempo constante):
responder 401 y terminar
si el número de transacción ya fue procesado:
responder 200 y terminar // no reintentar, no duplicar
procesar
si falló la base de datos:
responder 500 // que el emisor lo vuelva a mandar
si no:
responder 200La clave del webhook va siempre en una variable de entorno, nunca escrita en el código ni en el flujo a la vista. Y si alguna vez la pegas en un chat o en un documento compartido, cámbiala.
Dos ejemplos para aplicarlo
Ejemplo ficticio 1: la página de un despacho
El despacho ficticio «Paredes Legal» publica un formulario para que sus clientes cuenten su caso. Una noche, un programa envía 300 formularios en diez minutos desde una misma dirección. Con el límite de 5 por hora por dirección, entran como mucho cinco; si el programa llena el campo trampa, ni siquiera esos llegan a Notion. El abogado no recibe 300 avisos de Telegram, y si el programa cambia de dirección en cada envío, el tope por despacho lo corta en 40.
Ejemplo ficticio 2: un contador con un webhook de pagos
Un contador independiente ficticio recibe en n8n los avisos de cobro de su plataforma de pagos para emitir la factura. Configura el nodo Webhook con autenticación por cabecera, de modo que solo entra quien manda la clave correcta, y guarda el número de transacción en una tabla con valor único. Cuando la plataforma reintenta un aviso por una demora de red, la segunda copia choca con el valor único y no se emite una segunda factura.
Errores comunes
- Leer el primer valor de X-Forwarded-For para limitar la frecuencia. Lo controla el visitante.
- Contar por dirección IP detrás de un proxy o dentro del mismo servidor sin comprobar qué dirección llega de verdad. Puedes estar limitando a todo el mundo junto.
- Ponerle al campo trampa un nombre que lo delata, como «trampa» o «no_llenar», o dejarlo alcanzable con el teclado: una persona que navega con tabulador terminaría escribiendo en él.
- Responder con un error al robot detectado. Le enseñas qué evitar.
- Validar solo en el navegador.
- Dejar una ruta que envía correos a cualquier dirección sin tope. Tu dominio termina en listas de spam.
- Confiar en que nadie conoce la dirección del webhook.
- Procesar dos veces el mismo aviso de pago porque la plataforma reintentó.
- No enterarse cuando un flujo falla.
Lo que esto no resuelve
Estas capas frenan el abuso común: programas que llenan formularios, ráfagas, curiosos que encuentran un webhook. No son una defensa contra un ataque de denegación de servicio de verdad, que se frena antes de llegar a tu servidor, en la red o en un servicio especializado. Tampoco reemplazan a un captcha si un día el abuso se vuelve dirigido y persistente: si eso pasa, lo pondríamos sin dudar. Por ahora, para el tamaño de nuestro tráfico, las cinco capas invisibles han sido suficientes, y no le piden nada a quien escribe con un problema real.
Preguntas frecuentes
¿Por qué no usar un captcha y listo?
Puedes hacerlo. Nosotros preferimos no agregar un paso a quien cuenta un problema legal ni abrir nuestra política de seguridad a un script de terceros. Si el abuso se volviera dirigido, lo agregaríamos.
¿Qué es un campo trampa?
Un campo del formulario que una persona no ve ni alcanza con el teclado, pero que los programas automáticos completan. Si llega lleno, el envío se descarta en silencio.
¿Qué límite por hora conviene poner?
Depende de la ruta. Nosotros usamos entre 3 y 10 envíos por hora por dirección, y más generosos en una demostración pública. Lo importante es que haya también un tope global por ruta.
¿Cómo protejo un webhook de n8n?
El nodo Webhook permite autenticación básica, por cabecera o con JWT. Usa una clave larga guardada como credencial, nunca escrita a la vista, y cámbiala si se expone.
¿Los límites en memoria sirven si mi sitio corre en varios servidores?
Protegen menos: cada servidor tiene su propio contador. Para varios servidores conviene guardar los contadores en una base compartida.
¿Esto sirve para un formulario de un consultorio o un estudio contable?
Sí. Las capas son las mismas para cualquier formulario que reciba datos de personas y los pase a otro sistema.
Fuentes
- OWASP API Security Top 10 2023: API4, consumo de recursos sin restricción (consultado el 7 de octubre de 2026)
- OWASP: Input Validation Cheat Sheet (consultado el 7 de octubre de 2026)
- OWASP: Secrets Management Cheat Sheet (consultado el 7 de octubre de 2026)
- MDN: X-Forwarded-For (seguridad y valores de confianza) (consultado el 7 de octubre de 2026)
- MDN: 429 Too Many Requests (consultado el 7 de octubre de 2026)
- Documentación de n8n: nodo Webhook (autenticación y respuesta) (consultado el 7 de octubre de 2026)
- Documentación de n8n: nodo Error Trigger (consultado el 7 de octubre de 2026)
- Documentación de n8n: manejo de errores (consultado el 7 de octubre de 2026)
- Documentación de n8n: datos estáticos del flujo (getWorkflowStaticData) (consultado el 7 de octubre de 2026)
- Notion API: límites de tamaño (consultado el 7 de octubre de 2026)
Orientación general, no asesoría profesional. Revisa la fuente oficial y la fecha antes de decidir. Cómo escribimos · Avisar de un error
Sigue leyendo
Bitácora
Cómo armamos los avisos del despacho con n8n, Telegram y Notion (y lo que se rompió en el camino)Bitácora
Lo que aprendimos al publicar un sitio en un servidor propio: despliegue automático, cabeceras de seguridad y errores realesIA aplicada
Cómo pedirle un escrito a la IA sin que invente leyes: método, plantillas y verificación