Frena el spam en formularios de contacto

Frena el spam en formularios de contacto6 min de lectura

Cómo limitar la frecuencia de envíos de un formulario

Limita los envíos de un formulario por visitante con un almacén compartido, responde 429 con Retry-After y no bloquees oficinas que comparten una IP.

Preet SutharFundador, Ikiform

Cuenta los envíos por IP de visitante en un almacén que compartan todas las instancias del servidor, como Redis, permite una pequeña ráfaga y responde 429 con una cabecera Retry-After cuando se agote el cupo. Un contador en memoria solo funciona en un único servidor de larga duración, nunca en hosts serverless. Fija el límite según cómo usan el formulario las personas reales, y deja margen para oficinas y eventos donde mucha gente comparte una IP.

Qué tienen de especial los límites de frecuencia

Un límite de frecuencia no distingue el spam del correo legítimo. Frena el volumen: un script que envía en bucle, un bot que recorre tu formulario o una persona que machaca el botón. Eso lo convierte en la capa más barata, porque se ejecuta antes de leer el cuerpo, y en la más tosca, porque no distingue a un atacante de una oficina con mucho movimiento. Tres cosas deciden si ayuda o estorba: dónde vive el conteo, por qué cuentas y lo rápido que se recupera el cupo.

Una ráfaga de envíos de un visitante frente a un límite por visitante con recarga gradualenvíos 1 a3en 10 segundos:aceptadosenvío 4429 conRetry-Aftercada 12 sse permite unenvío másun minutotranquilocupo completootra vezráfagaesperarecargaUna ráfaga de envíos de un visitante frente a un límite por visitante con recarga gradualenvíos 1 a 3en 10 segundos: aceptadosenvío 4429 con Retry-Aftercada 12 sse permite un envío másun minuto tranquilocupo completo otra vezráfagaesperarecarga
  1. Un visitante manda 3 envíos en 10 segundos. Todos se aceptan.
  2. El siguiente se rechaza con 429 y una cabecera Retry-After que indica cuánto esperar.
  3. Después, se permite un envío más cada 12 segundos.
  4. Tras un minuto completo sin actividad, el cupo entero vuelve.
Son los números Standard de Ikiform: 5 por minuto, como máximo 3 cada 10 segundos. Una recarga gradual hace que nadie espere una ventana entera para un envío más.

Dónde vive el conteo

AlmacénFunciona enCuidado con
Un Map en memoriaUn único servidor de larga duraciónSe pierde al reiniciar. Cada instancia serverless tiene su propio map, así que el límite real es desconocido
Redis, como UpstashCualquier cosa, incluidos serverless y edgeUna llamada de red por envío. Elige una región cercana a tu servidor
Binding de rate limiting de Cloudflare WorkersWorkersAproximado y contado por ubicación de Cloudflare, solo con periodos de 10 o 60 segundos
Un proxy inverso, como limit_req de nginxServidores detrás de tu propio proxyCuenta por proxy. Necesita la IP real del cliente

El algoritmo importa menos que el almacén, pero define lo que notan los visitantes. Una ventana fija se reinicia cada minuto, así que un script puede mandar un cupo completo al final de un minuto y otro al principio del siguiente. Una ventana deslizante suaviza ese borde ponderando la ventana anterior. Un token bucket, o el algoritmo similar que usa Ikiform, recarga un envío cada vez, lo que permite a alguien que cometió una errata volver a enviar unos segundos después sin abrir la puerta a una ráfaga. Para un formulario de contacto, cualquiera sirve si los números encajan con cómo lo usa la gente.

Los ejemplos de abajo usan @upstash/ratelimit de Upstash, porque funciona igual en todos los frameworks y en hosts serverless. Define UPSTASH_REDIS_REST_URL y UPSTASH_REDIS_REST_TOKEN, y guarda el helper una vez:

lib/limit.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const contact = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, "60 s"),
  prefix: "contact",
});

export async function allow(ip: string | null | undefined) {
  const { success, reset } = await contact.limit(ip || "no-ip");
  return { ok: success, retryAfter: Math.max(1, Math.ceil((reset - Date.now()) / 1000)) };
}

reset es un tiempo Unix en milisegundos, así que el helper lo convierte en segundos enteros para Retry-After. Los envíos sin IP comparten un mismo bucket, lo que evita que una cabecera ausente se convierta en vía libre.

Aplícalo en tu framework

Un formulario HTML básico no tiene servidor que cuente nada. Envíalo a Ikiform y elige un preset en el formulario; un visitante que supera el límite ve una breve página que le pide ir más despacio en lugar de un error.

contact.html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" required></textarea>
  <button>Send</button>
</form>

En Cloudflare Workers, el binding integrado es la opción más barata:

wrangler.toml
[[ratelimits]]
name = "CONTACT_LIMIT"
namespace_id = "1001"

  [ratelimits.simple]
  limit = 5
  period = 60
src/index.ts
const ip = request.headers.get("CF-Connecting-IP") ?? "no-ip";
const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
if (!success) return new Response("Too many messages", { status: 429, headers: { "Retry-After": "60" } });

Cloudflare documenta ese binding como aproximado y contado por separado en cada ubicación, así que un ataque repartido puede superarlo. Para un límite exacto entre ubicaciones, Cloudflare recomienda un Durable Object. Detrás de tu propio nginx, limit_req hace el conteo antes de que tu app vea el envío:

nginx.conf
limit_req_zone $binary_remote_addr zone=contact:10m rate=5r/m;

server {
  location = /api/contact {
    limit_req zone=contact burst=3 nodelay;
    limit_req_status 429;
    proxy_pass http://127.0.0.1:3000;
  }
}

Por qué contar

  • IP y formulario juntos. La opción habitual. Un visitante no puede inundar este formulario, y usar otro formulario de tu sitio no gasta el mismo cupo.
  • Muchas personas, una IP. Oficinas, colegios, operadoras móviles y el Wi-Fi de una conferencia ponen a mucha gente detrás de una misma dirección. Mantén el límite por IP lo bastante holgado para ellos, o súbelo los días de evento.
  • La IP real del cliente. Detrás de un proxy o una CDN, lee la cabecera que pone tu host y confía solo en esa. El cliente puede fijar un valor de X-Forwarded-For en bruto.
  • La IP de tu propio servidor. Si tu servidor reenvía envíos a otro servicio, allí todos los visitantes comparten la IP de tu servidor. Limita en tu lado, por la IP del visitante, antes de reenviar.

Los límites de frecuencia de Ikiform

Ikiform cuenta cada envío con exactitud, por IP de visitante y formulario, antes de leer el cuerpo, con una recarga gradual en lugar de ventanas fijas. Cada formulario elige un preset en Rate limit. Antes que cualquiera de ellos se ejecuta una protección contra floods de 600 por minuto por IP en todos los formularios.

PresetPor visitanteIdeal para
Standard5 por minuto, como máximo 3 cada 10 segundosLa mayoría de los formularios
Relaxed20 por minutoEventos con Wi-Fi compartido, formularios tras un inicio de sesión
Strict2 cada 10 minutos, uno más cada 5 minutosFormularios de contacto y de ventas
CustomDe 1 a 1.000 por 10 segundos, minuto, 10 minutos, hora o díaUna entrada por persona, quioscos

El formulario entero además admite 300 por minuto entre todos los visitantes, salvo que fijes un tope Custom. Por encima del límite, un envío del navegador ve una página que pide ir más despacio y fetch recibe 429 rate_limited con Retry-After, que Ikiform expone por CORS para que el código de arriba pueda leerlo. La documentación de límites de frecuencia explica la recarga.

Las versiones de servidor envían con una clave secreta, así que Ikiform se salta Allowed domains y su verificación humana. Todos los envíos llegan entonces desde la IP de tu servidor, y el límite Standard de 5 por minuto por visitante cuenta a todos tus visitantes juntos. Elige el límite de frecuencia Relaxed o Custom para un formulario que envías a través de un servidor, y guarda IKIFORM_SECRET_KEY en variables de entorno solo de servidor.

Comprueba que funciona

  1. Envía el formulario varias veces seguidas. Pasado el límite recibes 429 con una cabecera Retry-After, o la página de Ikiform que pide ir más despacio.
  2. Espera los segundos que indique Retry-After y vuelve a enviar. Se acepta.
  3. Envía desde una segunda red, como tu móvil sin Wi-Fi. Tiene su propio cupo.
  4. Si tu servidor reenvía los envíos, envía rápido desde dos dispositivos y comprueba que ninguno comparte el conteo del otro.

Trampas habituales

  • Los envíos rechazados no deberían gastar cupo. Cuenta antes del trabajo, y no dejes que un envío rechazado aleje más la ventana, o un visitante nervioso se bloqueará a sí mismo.
  • No limites la carga de la página. Cuenta envíos, no visitas al formulario, o una persona que recarga gastará sus envíos.
  • 429 rate_limited Un visitante envió más de lo que permite el límite de frecuencia del formulario. Standard permite 5 por minuto por visitante. Espera lo que indique Retry-After, o elige Relaxed o Custom para formularios que envían desde una IP compartida.
  • IPv6. Una persona puede tener muchas direcciones IPv6. Para un límite estricto, cuenta por el prefijo /64 en lugar de la dirección completa.

Preguntas

¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.