Frena el spam en formularios de contacto

Frena el spam en formularios de contacto8 min de lectura

Protección antispam para formularios de Nuxt con Turnstile

Protege un formulario de Nuxt del spam: una server route de Nitro con Turnstile, un límite por IP en Nitro storage, secretos en runtimeConfig y una CSP en routeRules.

Preet SutharFundador, Ikiform

Envía el formulario con $fetch a una server route que cuenta los intentos por IP de cliente en Nitro storage, verifica el token de Turnstile con siteverify y reenvía los campos con el honeypot. Guarda los secretos en el runtimeConfig privado, definidos desde variables de entorno NUXT_ en tiempo de ejecución. Envía la CSP de la página de contacto desde routeRules, permitiendo challenges.cloudflare.com para el script y su frame.

Qué cambia en Nuxt

El servidor de Nuxt es Nitro, y Nitro aporta dos cosas que necesita una comprobación antispam. Las server routes de server/api son endpoints sencillos con $fetch ya conectado a ellos, y useStorage() te da un almacén clave-valor cuyo backend eliges por entorno: memoria en desarrollo, Redis u otro driver en producción. Eso convierte el límite de frecuencia en un cambio de configuración en lugar de una dependencia.

Dos costumbres de Nuxt causan problemas aquí. getRequestIP(event, { xForwardedFor: true }) devuelve la primera entrada de X-Forwarded-For, que la propia documentación de h3 considera entrada del cliente. Y los archivos .env no se leen en producción, así que los secretos deben definirse como variables de entorno de la plataforma.

Un envío de contacto a través de la server route de Nuxt y Nitro storageContactForm.vue$fetchconeltokenclientIp(event)cabeceraderuntimeConfiguseStoragecontaresteminutositeverifyactionyhostIkiformguarda,puntúaelhoneypot429segundosquequedanenlaventanaPOST /api/contactmenos de 5humanopor encimaUn envío de contacto a través de la server route de Nuxt y Nitro storageContactForm.vue$fetch con el tokenclientIp(event)cabecera deruntimeConfiguseStoragecontar esteminutositeverifyaction y hostIkiformguarda, puntúa elhoneypot429segundos quequedan en laventanaPOST /api/contactmenos de 5humanopor encima
  1. El componente envía los campos y el token de Turnstile a /api/contact con $fetch.
  2. La server route lee la IP del cliente de la cabecera indicada en runtimeConfig.
  3. Cuenta los envíos de esa IP en el minuto actual en Nitro storage. Por encima de cinco, lanza un 429 con los segundos que quedan.
  4. Bajo el límite, verifica el token con siteverify, comprobando la action y el host.
  5. Luego reenvía los campos a Ikiform, que los guarda y puntúa el honeypot.
En desarrollo el almacenamiento está montado en memoria. En producción el mismo código habla con Redis, porque nuxt.config monta otro driver.

Configuración del proyecto

Archivos
app/
  components/
    ContactForm.vue
  composables/
    useTurnstile.ts
server/
  api/
    contact.post.ts
  utils/
    spam.ts
nuxt.config.ts
.env
.env
NUXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
NUXT_TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_CLIENT_IP_HEADER=x-forwarded-for
nuxt.config.ts
const policy = [
  "default-src 'self'",
  "script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com",
  "frame-src https://challenges.cloudflare.com",
  "object-src 'none'",
  "base-uri 'self'",
].join("; ");

export default defineNuxtConfig({
  runtimeConfig: {
    turnstileSecretKey: "",
    ikiformSecretKey: "",
    clientIpHeader: "x-forwarded-for",
    public: {
      turnstileSiteKey: "",
    },
  },
  routeRules: {
    "/contact": { headers: { "Content-Security-Policy": policy } },
  },
  $production: {
    nitro: {
      storage: {
        ratelimit: { driver: "upstash" },
      },
    },
  },
});
  • Cualquier clave de runtimeConfig puede sobrescribirse en tiempo de ejecución con una variable NUXT_: turnstileSecretKey con NUXT_TURNSTILE_SECRET_KEY, public.turnstileSiteKey con NUXT_PUBLIC_TURNSTILE_SITE_KEY. Las claves fuera de public nunca llegan al navegador.
  • routeRules envía la CSP solo en /contact. Nuxt incrusta en línea su payload y sus scripts de hidratación, así que esta política mantiene 'unsafe-inline'; el módulo de la comunidad nuxt-security puede generar nonces si necesitas una política más estricta.
  • El driver upstash solo se monta en producción y lee UPSTASH_REDIS_REST_URL y UPSTASH_REDIS_REST_TOKEN del entorno. Necesita el paquete @upstash/redis, así que instálalo con npm install @upstash/redis.

Utilidades de servidor

Los archivos de server/utils se importan automáticamente en todas las server routes:

server/utils/spam.ts
import type { H3Event } from "h3";

const WINDOW_SECONDS = 60;
const LIMIT = 5;

export function clientIp(event: H3Event) {
  const header = useRuntimeConfig(event).clientIpHeader;
  return getRequestHeader(event, header)?.split(",")[0]?.trim() || "unknown";
}

export async function secondsToWait(ip: string) {
  const storage = useStorage("ratelimit");
  const window = Math.floor(Date.now() / 1000 / WINDOW_SECONDS);
  const key = `contact:${ip}:${window}`;
  const count = ((await storage.getItem<number>(key)) ?? 0) + 1;
  await storage.setItem(key, count);
  if (count <= LIMIT) return 0;
  return (window + 1) * WINDOW_SECONDS - Math.floor(Date.now() / 1000);
}

export async function isHuman(event: H3Event, token: string, ip: string) {
  if (!token || token.length > 2048) return false;
  try {
    const verdict = await $fetch<{ success: boolean; hostname?: string; action?: string }>("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
      method: "POST",
      body: { secret: useRuntimeConfig(event).turnstileSecretKey, response: token, remoteip: ip },
    });
    return verdict.success && verdict.action === "contact" && (import.meta.dev || verdict.hostname === getRequestHost(event));
  } catch {
    return false;
  }
}
  • clientIp lee exactamente una cabecera, la que sobrescribe tu host. Pon NUXT_CLIENT_IP_HEADER en x-forwarded-for en Vercel, x-nf-client-connection-ip en Netlify y cf-connecting-ip en Cloudflare.
  • El contador es una ventana fija en Nitro storage. Leer y luego escribir no es atómico, así que dos envíos en el mismo instante pueden colarse ambos bajo el límite; para un formulario de contacto es aceptable. Las claves no caducan solas, así que dale a la base de datos de Redis una política de expulsión.
  • $fetch envía el cuerpo a siteverify como JSON, que siteverify acepta. La comprobación del host usa getRequestHost y se omite en desarrollo, porque los tokens de prueba llevan un hostname ficticio.

La server route

server/api/contact.post.ts
interface ContactBody {
  name?: string;
  email?: string;
  company?: string;
  message?: string;
  _gotcha?: string;
  "cf-turnstile-response"?: string;
}

export default defineEventHandler(async (event) => {
  const ip = clientIp(event);
  const wait = await secondsToWait(ip);
  if (wait) throw createError({ status: 429, message: `Too many messages. Try again in ${wait} seconds.` });

  const body = await readBody<ContactBody>(event);
  const fields = {
    name: body.name?.trim().slice(0, 120) ?? "",
    email: body.email?.trim() ?? "",
    company: body.company?.trim().slice(0, 120) ?? "",
    message: body.message?.trim().slice(0, 5000) ?? "",
    _gotcha: body._gotcha ?? "",
  };
  if (!fields.email.includes("@")) throw createError({ status: 400, message: "Enter an address we can reply to." });
  if (!(await isHuman(event, body["cf-turnstile-response"] ?? "", ip))) {
    throw createError({ status: 403, message: "We couldn't confirm you're human. Try again." });
  }

  const origin = getRequestHeader(event, "origin");
  await $fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      Authorization: `Bearer ${useRuntimeConfig(event).ikiformSecretKey}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: fields,
  }).catch((failure) => {
    throw createError({
      status: failure.status ?? 502,
      message: failure.data?.error?.message ?? "Your message didn't send. Try again.",
    });
  });
  return { sent: true };
});
  • El sufijo .post.ts hace que Nitro solo responda a POST. Cualquier otra cosa recibe 405.
  • El límite se ejecuta antes de readBody, así que un flood nunca cuesta leer un cuerpo ni una llamada a siteverify.
  • Cada fallo lanza createError. El cuerpo JSON lleva message, que el componente muestra.
  • El Origin del visitante se reenvía, porque un fetch del lado del servidor no envía ninguno e Ikiform lo lee para reconocer las pruebas gratuitas desde localhost.
  • El honeypot _gotcha viaja intacto, así que Ikiform guarda uno relleno como spam en lugar de descartarlo.
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.

El composable y el formulario

app/composables/useTurnstile.ts
interface TurnstileApi {
  render(
    container: HTMLElement,
    options: { sitekey: string; action: string; callback: (token: string) => void; "expired-callback": () => void },
  ): string;
  reset(widgetId: string): void;
  remove(widgetId: string): void;
}

let loading: Promise<TurnstileApi> | undefined;

function load() {
  loading ??= new Promise<TurnstileApi>((resolve, reject) => {
    const script = document.createElement("script");
    script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
    script.async = true;
    script.onload = () => {
      const api = (window as Window & { turnstile?: TurnstileApi }).turnstile;
      if (api) resolve(api);
      else reject(new Error("Turnstile did not load"));
    };
    script.onerror = () => reject(new Error("Turnstile did not load"));
    document.head.append(script);
  });
  return loading;
}

export function useTurnstile(action: string) {
  const { turnstileSiteKey } = useRuntimeConfig().public;
  const box = ref<HTMLElement>();
  const token = ref("");
  let api: TurnstileApi | undefined;
  let widgetId: string | undefined;

  onMounted(async () => {
    api = await load();
    if (!box.value) return;
    widgetId = api.render(box.value, {
      sitekey: turnstileSiteKey,
      action,
      callback: (value) => (token.value = value),
      "expired-callback": () => (token.value = ""),
    });
  });

  onBeforeUnmount(() => {
    if (widgetId) api?.remove(widgetId);
  });

  function reset() {
    token.value = "";
    if (widgetId) api?.reset(widgetId);
  }

  return { box, token, reset };
}
app/components/ContactForm.vue
<script setup lang="ts">
const fields = reactive({ name: "", email: "", company: "", message: "", _gotcha: "" });
const { box, token, reset } = useTurnstile("contact");
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");

async function send() {
  status.value = "sending";
  error.value = "";
  try {
    await $fetch("/api/contact", { method: "POST", body: { ...fields, "cf-turnstile-response": token.value } });
    status.value = "sent";
  } catch (failure) {
    error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't send your message. Try again.";
    status.value = "idle";
    reset();
  }
}
</script>

<template>
  <p v-if="status === 'sent'" role="status">Thanks. We'll reply by email.</p>
  <form v-else @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" v-model="fields.name" autocomplete="name" required maxlength="120" />
    <label for="email">Email</label>
    <input id="email" v-model="fields.email" type="email" autocomplete="email" required />
    <label for="company">Company <span>(optional)</span></label>
    <input id="company" v-model="fields.company" autocomplete="organization" maxlength="120" />
    <label for="message">Message</label>
    <textarea id="message" v-model="fields.message" rows="5" required minlength="10" />
    <div class="hp" aria-hidden="true">
      <label for="hp-field">Leave this empty</label>
      <input id="hp-field" v-model="fields._gotcha" type="text" tabindex="-1" autocomplete="off" />
    </div>
    <div ref="box" />
    <p v-if="error" role="alert">{{ error }}</p>
    <button :disabled="status === 'sending' || !token">{{ status === "sending" ? "Sending" : "Send message" }}</button>
  </form>
</template>
  • El composable solo se ejecuta en onMounted, así que el renderizado en el servidor nunca toca window.
  • El botón espera a tener un token, y reset() pide uno nuevo después de cada fallo, porque un token sirve una vez y dura 300 segundos.
  • Este formulario necesita JavaScript. Para una versión que envía sin él, consulta la sección siguiente.
  • La clase .hp oculta el honeypot fuera de pantalla, como en la guía del honeypot.

Pruébalo en local

En desarrollo el montaje ratelimit está en memoria, así que el conteo se reinicia cuando se reinicia nuxt dev. Puedes ver funcionar el límite sin navegador. Los envíos sin token fallan en siteverify con 403, y cada uno cuenta igualmente:

Terminal
for i in 1 2 3 4 5 6; do
  curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/api/contact \
    -H "Content-Type: application/json" -d '{"email":"test@example.com"}'
done

Las cinco primeras líneas imprimen 403 y la sexta imprime 429. En local ningún proxy fija la cabecera de IP, así que todas las peticiones cuentan como unknown; es lo esperado, y es la razón de que la cabecera viva en la configuración y no en el código.

Despliegue

HostNUXT_CLIENT_IP_HEADERDriver de almacenamiento
Vercelx-forwarded-forupstash, desde el Vercel Marketplace
Netlifyx-nf-client-connection-ipupstash o redis
Cloudflarecf-connecting-ipupstash; Workers KV tiene consistencia eventual, así que los conteos van con retraso
Servidor NodeLa cabecera que fija tu proxyredis, o memoria en una sola instancia

Define todas las variables NUXT_ en el panel del host. Nuxt las lee al arrancar el servidor, así que cambiar una clave requiere un reinicio, no un nuevo build. Añade cada hostname de producción y de preview al widget de Turnstile.

Deja que Ikiform haga las comprobaciones

Sin la server route, la página de contacto puede prerenderizarse con el resto de un sitio nuxt generate. Apunta un formulario básico a tu endpoint de Ikiform con method="post" y action con la URL de tu endpoint, y elimina server/api/contact.post.ts y el composable.

Pon el Spam check del formulario en Ikiform. Un envío sin token recibe una breve página de verificación en ikiform.com que ejecuta Turnstile y reenvía los campos, así que tu CSP no necesita entradas de Cloudflare. Elige el Rate limit Strict, 2 cada 10 minutos por visitante, e indica tu dominio en Allowed domains. Ikiform conserva el spam atrapado durante 30 días con los motivos por los que se atrapó. La guía de configuración de Nuxt cubre el endpoint y las claves.

Comprueba que funciona

  1. Ejecuta nuxt dev con las claves de prueba y envía un mensaje. Aparece en Submissions. Un espacio de trabajo sin plan acepta 50 pruebas desde localhost al mes.
  2. Envía seis mensajes en un minuto. El sexto muestra cuántos segundos quedan.
  3. Pon NUXT_TURNSTILE_SECRET_KEY con el secreto que siempre falla y reinicia. Cada envío muestra el error de verificación humana.
  4. Pide /contact con curl -I después de nuxt build. La respuesta lleva tu Content-Security-Policy.
  5. 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.

Trampas habituales

ProblemaQué hacer
Los secretos están vacíos en producción.env no se lee en tiempo de ejecución en producción. Define NUXT_TURNSTILE_SECRET_KEY y las demás en el entorno del host.
El límite se reinicia en cada despliegue o instanciaEl almacenamiento sigue montado en memoria. Comprueba que $production.nitro.storage monta un driver compartido y que sus credenciales están definidas.
Un script supera el límite cambiando cabecerasLees getRequestIP con xForwardedFor: true en un host que no sobrescribe esa cabecera. Lee la cabecera propia del host con clientIp.
window is not defined durante el renderizado en el servidorEl código de Turnstile se ejecutó fuera de onMounted. Mantén todo acceso a window dentro de los hooks de ciclo de vida del composable.

Preguntas

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