Frena el spam en formularios de contacto

Frena el spam en formularios de contacto7 min de lectura

Cómo frenar el spam en formularios sin captcha

Frena casi todo el spam de tus formularios sin ningún captcha: honeypot, hora de renderizado firmada, puntuación del contenido y límite de frecuencia, sumados en lugar de usarlos por separado.

Preet SutharFundador, Ikiform

Combina cuatro comprobaciones invisibles y suma lo que encuentren: un honeypot, una marca de tiempo firmada que muestra cuánto tiempo estuvo abierto el formulario, una puntuación para enlaces y frases de spam, y un límite de frecuencia por visitante. Trata un envío como spam solo cuando el total supere un umbral, para que ninguna señal leve por sí sola bloquee a una persona real. Guarda lo que atrapes, y añade un captcha más adelante solo si la carpeta de spam muestra que siguen colándose bots.

Qué cambia sin captcha

Un captcha te da un sí o un no rotundo. Sin él, tienes en cambio varias señales débiles, y cada una por separado es fácil de esquivar o fácil de activar por accidente. Un envío rápido puede ser un bot, o alguien que pegó un mensaje preparado. Un enlace puede ser spam, o un portfolio. El truco es puntuar: dar un peso a cada señal, sumarlas y solo llamarlo spam pasado un umbral. La única señal fuerte, un honeypot relleno, tiene un peso suficiente para cruzar la línea por sí sola.

Cómo las señales invisibles se suman hasta un veredicto en tres envíos distintosUn bot de scriptUna oferta de SEO pegadaUn mensaje real con tres enlaceshoneypot relleno+5enviado en 0,4 s+38: spamenlace en el nombre+3"seo services"+25: spamtres enlaces+2nada más2: recibidoCómo las señales invisibles se suman hasta un veredicto en tres envíos distintosUn bot de scriptUna oferta de SEO pegadaUn mensaje real con tres enlaceshoneypot relleno+5enviado en 0,4 s+38: spamenlace en el nombre+3"seo services"+25: spamtres enlaces+2nada más2: recibido
  1. Un bot de script rellena el honeypot, que suma 5, y envía en menos de un segundo, que suma 3. Su total de 8 es spam.
  2. Una oferta de SEO pegada tiene un enlace en el campo del nombre, 3, y una frase de spam conocida, 2. Su total de 5 alcanza el umbral de 5.
  3. Un mensaje real con tres enlaces puntúa 2 y nada más, así que se recibe.
Los pesos son los de Ikiform, con un umbral de 5. Dos señales leves juntas lo cruzan; una sola, nunca.

Una hora de renderizado firmada

Las personas necesitan varios segundos para rellenar un formulario. Una trampa de tiempo mide ese intervalo. La versión débil pone Date.now() en un campo oculto, que cualquier bot puede reescribir. La versión fuerte firma la hora de renderizado en el servidor con un secreto, así que un bot no puede afirmar que la página se abrió antes de lo que se abrió. Este archivo funciona en cualquier entorno con Web Crypto: Node 20 o posterior, Deno, Bun, Workers y cualquier runtime serverless.

lib/render-token.ts
const encoder = new TextEncoder();

async function key(secret: string) {
  return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
    "sign",
    "verify",
  ]);
}

function toHex(bytes: ArrayBuffer) {
  return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}

function fromHex(hex: string) {
  return new Uint8Array((hex.match(/../g) ?? []).map((pair) => Number.parseInt(pair, 16)));
}

export async function signRender(secret: string, now = Date.now()) {
  const signature = await crypto.subtle.sign("HMAC", await key(secret), encoder.encode(`render.${now}`));
  return `${now}.${toHex(signature)}`;
}

export type RenderCheck = "ok" | "too_fast" | "invalid";

export async function checkRender(secret: string, token: string, minSeconds = 3, now = Date.now()): Promise<RenderCheck> {
  const [time = "", signature = ""] = token.split(".");
  if (!/^\d{13}$/.test(time) || !/^[0-9a-f]{64}$/.test(signature)) return "invalid";
  const valid = await crypto.subtle.verify("HMAC", await key(secret), fromHex(signature), encoder.encode(`render.${time}`));
  const age = now - Number(time);
  if (!valid || age < 0 || age > 86_400_000) return "invalid";
  return age < minSeconds * 1000 ? "too_fast" : "ok";
}

Firma cuando se renderiza la página, así que la página debe renderizarse en cada petición, no cachearse como HTML estático. En Astro:

src/pages/contact.astro
---
import { signRender } from "../lib/render-token";

export const prerender = false;

const rendered = await signRender(import.meta.env.RENDER_SECRET);
---

<form method="POST" action="/api/contact">
  <input type="hidden" name="rendered" value={rendered} />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <label for="name">Name</label>
  <input id="name" name="name" required />
  <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>
  • Tres segundos es un mínimo seguro para un formulario de contacto. Alguien que pega un mensaje preparado puede tardar aun así dos.
  • Rechaza los tokens de más de un día. Si no, un bot carga la página una vez y reutiliza el mismo token durante semanas.
  • Trata un token ausente o falsificado como una señal, no como una prueba. Una página en caché o una pestaña antigua pueden producir uno.

Puntúa el envío

Reúne las señales en una función. Las comprobaciones de enlaces y frases atrapan lo que el honeypot y el tiempo no pueden: mensajes escritos o pegados por personas. Mantén la lista de frases corta y específica; palabras amplias como free marcan a clientes reales.

lib/score.ts
const WEIGHTS = { honeypot: 5, too_fast: 3, invalid_render: 3, link_in_name: 3, many_links: 2, phrase: 2 };
const THRESHOLD = 5;
const LINK = /https?:\/\/|www\./gi;
const PHRASES = /\b(seo services|guest post|backlinks|first page of google|crypto investment)\b/i;

type Reason = keyof typeof WEIGHTS;

export function scoreContact(input: {
  name: string;
  message: string;
  trap: string;
  render: "ok" | "too_fast" | "invalid";
}) {
  const reasons: Reason[] = [];
  if (input.trap.trim()) reasons.push("honeypot");
  if (input.render === "too_fast") reasons.push("too_fast");
  if (input.render === "invalid") reasons.push("invalid_render");
  if (input.name.match(LINK)) reasons.push("link_in_name");
  if ((`${input.name} ${input.message}`.match(LINK)?.length ?? 0) >= 3) reasons.push("many_links");
  if (PHRASES.test(`${input.name} ${input.message}`)) reasons.push("phrase");
  const score = reasons.reduce((total, reason) => total + WEIGHTS[reason], 0);
  return { score, reasons, spam: score >= THRESHOLD };
}
src/pages/api/contact.ts
import type { APIRoute } from "astro";
import { checkRender } from "../../lib/render-token";
import { scoreContact } from "../../lib/score";

export const POST: APIRoute = async ({ request, redirect }) => {
  const form = await request.formData();
  const render = await checkRender(import.meta.env.RENDER_SECRET, String(form.get("rendered") ?? ""));
  const verdict = scoreContact({
    name: String(form.get("name") ?? ""),
    message: String(form.get("message") ?? ""),
    trap: String(form.get("_gotcha") ?? ""),
    render,
  });
  await saveMessage(Object.fromEntries(form), verdict.spam ? "spam" : "received", verdict.reasons);
  return redirect("/thanks", 303);
};

El handler guarda todo con un estado en lugar de descartar el spam, y responde a ambos con la misma redirección. Revisas el spam después, y un bot nunca sabe qué señal lo atrapó. Añade un límite de frecuencia por visitante delante de esta ruta para frenar los floods antes de que se ejecute nada de esto.

Ikiform los ejecuta sin captcha

Cada formulario de Ikiform ejecuta el honeypot, el tiempo, la puntuación del contenido y los límites de frecuencia sin configuración, y su Spam check empieza en Off. El honeypot es _gotcha. La puntuación del contenido revisa enlaces, 22 frases de spam conocidas, dominios de email desechables y repeticiones idénticas en 24 horas. El límite de frecuencia empieza en 5 por minuto por visitante. Con 5 o más, un envío es spam.

SeñalPeso
Honeypot relleno+5
Enviado menos de 2 segundos después del renderizado firmado+3
Hora de renderizado falsificada o de hace más de 24 horas+3
Un enlace en un campo de nombre o asunto+3
Campos idénticos en 24 horas+3
Tres o más enlaces+2
Una frase de spam conocida+2
Un dominio de email desechable+2

La hora de renderizado firmada de Ikiform viene del esquema público del formulario. El componente de shadcn/ui la envía por ti. Un formulario que renderizas tú puede obtenerla con getSchema() de @ikiform/sdk y devolverla como _ikiform_ts:

contact.js
import { getSchema, submit } from "@ikiform/sdk";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";
const form = document.querySelector("form");
const schema = await getSchema(FORM_ID);
const rendered = schema.ok ? schema.schema.ts : null;

form?.addEventListener("submit", async (event) => {
  event.preventDefault();
  const data = new FormData(form);
  if (rendered) data.set("_ikiform_ts", rendered);
  const result = await submit(FORM_ID, data);
  form.replaceWith(result.ok ? "Thanks, we'll be in touch." : result.error.message);
});

Los formularios HTML básicos no envían hora de renderizado y no se les penaliza por ello, así que que falte no cuesta nada. Un envío considerado spam recibe la misma respuesta de éxito que uno real, así que el bot no aprende nada. Se guarda con el estado spam, no envía email de notificación ni webhook, no cuenta para tu uso y se elimina a los 30 días. Cada lunes, un espacio de trabajo que haya bloqueado spam recibe un email de resumen con el total, los formularios con más actividad y los motivos principales. El endpoint del esquema y la documentación de spam tienen los detalles.

Si aún se cuelan algunos bots, pon Spam check en Ikiform. Añade Cloudflare Turnstile sin claves, y la mayoría de los visitantes no ven nada. Ese es el momento de añadir un captcha: cuando lo diga la carpeta de spam, no antes.

Comprueba que funciona

  1. Carga el formulario y envíalo en menos de un segundo. Con tu propio handler, el envío se guarda como spam con too_fast.
  2. Edita el valor oculto rendered en las herramientas de desarrollo del navegador y envía. Puntúa como invalid_render.
  3. Envía con el honeypot relleno, _gotcha=bot. Sigue recibiendo una respuesta de éxito, pero el envío se guarda como spam y no envía email ni webhook.
  4. Envía un mensaje con tres enlaces y nada más sospechoso. Se recibe.
  5. ¿No llegó nada? Cambia el filtro de estado del panel a Spam. El spam recibe la misma respuesta de éxito que un envío real.

Trampas habituales

  • Páginas estáticas. Una página generada una vez al desplegar tiene una sola hora de renderizado para todos los visitantes. Fírmala en cada petición, u obtén un token nuevo con JavaScript cuando aparezca el formulario.
  • Gestores de contraseñas y pegar. Algunas personas rellenan un formulario en un segundo con el autocompletado. Mantén el tiempo como una señal leve para que nunca bloquee por sí solo.
  • Las listas de frases caducan. Las frases de spam cambian. Revisa lo que se cuela cada pocas semanas y añade la frase concreta, nunca una palabra común.
  • Las repeticiones son normales. La gente reenvía cuando no recibe respuesta. Dale peso a una repetición idéntica, pero no la bloquees sin más.

Preguntas

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