Frena el spam en formularios de contacto

Frena el spam en formularios de contacto8 min de lectura

Protección antispam para formularios de SvelteKit con Turnstile

Protege un formulario de SvelteKit del spam: una form action con getClientAddress, un límite por IP, Turnstile con use:enhance y una CSP que lo permite.

Preet SutharFundador, Ikiform

Haz las comprobaciones en una form action: limita por getClientAddress(), verifica el token de Turnstile con siteverify y luego reenvía los campos con el honeypot. Renderiza el widget con una action de Svelte y reinícialo en el callback de use:enhance, porque cada token sirve una vez. Permite challenges.cloudflare.com en la opción csp del plugin sveltekit(), y en adapter-node indica a SvelteKit qué cabecera del proxy lleva la IP real.

Qué cambia en SvelteKit

SvelteKit te da directamente la IP del visitante: getClientAddress() en el request event. De dónde sale ese valor depende del adapter, y en adapter-node el valor por defecto es incorrecto detrás de cualquier proxy. Resuelve eso y el resto de las comprobaciones encajan sin problema en una form action.

De dónde saca getClientAddress() la IP del visitante en cada adapterform actionllama agetClientAddress()tu adapteradapter-nodedirección del socket, oADDRESS_HEADERadapter-verceldel edge de Verceladapter-netlifydel edge de Netlifyadapter-cloudflarede cf-connecting-ipDe dónde saca getClientAddress() la IP del visitante en cada adapterform actionllama agetClientAddress()tu adapteradapter-nodedirección delsocket, oADDRESS_HEADERadapter-verceldel edge de Verceladapter-netlifydel edge de Netlifyadapter-cloudflaredecf-connecting-ip
  1. La form action pide la dirección del cliente al request event.
  2. SvelteKit delega la respuesta en el adapter con el que despliegas.
  3. adapter-node usa la dirección remota del socket salvo que definas ADDRESS_HEADER y XFF_DEPTH.
  4. Los adapters de Vercel, Netlify y Cloudflare leen la dirección que da su plataforma, así que no hay nada que configurar.
Detrás de nginx o un balanceador de carga, adapter-node ve la dirección del proxy para todos los visitantes hasta que defines ADDRESS_HEADER, y una dirección compartida convierte un límite por IP en uno para todo el sitio.

El otro detalle de SvelteKit es use:enhance. Envía sin recargar la página, lo que es más cómodo para las personas pero significa que Turnstile nunca se vuelve a renderizar por sí solo. Hay que reiniciar el widget a mano después de cada respuesta.

Configuración del proyecto

Archivos
src/
  env.ts
  lib/
    turnstile.ts
    server/
      spam.ts
  routes/
    contact/
      +page.server.ts
      +page.svelte
vite.config.ts
.env
.env
PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
UPSTASH_REDIS_REST_URL=https://your-db.upstash.io
UPSTASH_REDIS_REST_TOKEN=your_token
src/env.ts
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  PUBLIC_TURNSTILE_SITE_KEY: { public: true },
  TURNSTILE_SECRET_KEY: {},
  IKIFORM_SECRET_KEY: {},
  UPSTASH_REDIS_REST_URL: {},
  UPSTASH_REDIS_REST_TOKEN: {},
});
  • SvelteKit 3 lee las variables que declaras en src/env.ts. Solo la site key tiene public: true, así que es la única que $app/env/public puede pasar al navegador. Nada de un directorio server, como src/lib/server, puede importarse desde código de cliente; SvelteKit hace fallar el build si lo intentas.
  • Las variables declaradas se leen al arrancar la app salvo que pongas static: true, así que una clave rotada necesita un reinicio, no un nuevo build. Los módulos $env/* siguen funcionando pero están obsoletos en SvelteKit 3.
  • Las claves de prueba de Cloudflare pasan en cualquier host, incluido localhost.
vite.config.ts
import adapter from "@sveltejs/adapter-node";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    sveltekit({
      adapter: adapter(),
      csp: {
        mode: "auto",
        directives: {
          "script-src": ["self", "https://challenges.cloudflare.com"],
          "frame-src": ["https://challenges.cloudflare.com"],
          "object-src": ["none"],
          "base-uri": ["self"],
        },
      },
    }),
  ],
});

SvelteKit 3 recibe sus opciones en vite.config.ts, pasadas al plugin sveltekit(); svelte.config.js ya no se lee. Con mode: "auto", SvelteKit añade nonces a sus propios scripts en línea en las páginas renderizadas en el servidor y hashes en las prerenderizadas, así que la política no necesita 'unsafe-inline'. Turnstile se carga desde su host, que script-src permite, y dibuja un iframe que frame-src permite.

Los helpers solo de servidor

src/lib/server/spam.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import { dev } from "$app/env";
import { TURNSTILE_SECRET_KEY, UPSTASH_REDIS_REST_TOKEN, UPSTASH_REDIS_REST_URL } from "$app/env/private";

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

export async function secondsToWait(ip: string) {
  const { success, reset } = await limiter.limit(ip);
  return success ? 0 : Math.max(1, Math.ceil((reset - Date.now()) / 1000));
}

export async function isHuman(token: string, ip: string, host: string) {
  if (!token || token.length > 2048) return false;
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ secret: TURNSTILE_SECRET_KEY, response: token, remoteip: ip }),
    });
    const verdict = (await response.json()) as { success: boolean; hostname?: string; action?: string };
    return verdict.success && verdict.action === "contact" && (dev || verdict.hostname === host);
  } catch {
    return false;
  }
}
  • El límite es una ventana deslizante en Redis, compartida por todas las instancias del servidor: cinco mensajes por minuto por IP. reset vuelve en milisegundos y se convierte en segundos enteros de espera.
  • dev de $app/env omite la comprobación del hostname en local, porque los tokens de prueba llevan un hostname ficticio.
  • Siteverify responde timeout-or-duplicate para un token de más de 300 segundos o ya usado, y por eso la página reinicia el widget.

La form action

src/routes/contact/+page.server.ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import { isHuman, secondsToWait } from "#lib/server/spam.js";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request, getClientAddress, url }) => {
    const ip = getClientAddress();
    const wait = await secondsToWait(ip);
    if (wait) return fail(429, { wait, error: `Too many messages. Try again in ${wait} seconds.` });

    const form = await request.formData();
    const values = {
      name: String(form.get("name") ?? "").trim(),
      email: String(form.get("email") ?? "").trim(),
      message: String(form.get("message") ?? "").trim(),
    };
    if (values.message.length < 10) return fail(400, { values, error: "A few words more, please." });
    if (!(await isHuman(String(form.get("cf-turnstile-response") ?? ""), ip, url.hostname))) {
      return fail(403, { values, error: "We couldn't confirm you're human. Try again." });
    }

    form.delete("cf-turnstile-response");
    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("origin") ?? url.origin,
      },
      body: form,
    }).catch(() => null);
    if (!response?.ok) return fail(502, { values, error: "Your message didn't send. Try again." });
    return { sent: true };
  },
} satisfies Actions;
  • El límite se ejecuta antes de leer el cuerpo. fail(429) mantiene al visitante en la página con el mensaje.
  • Cualquier otro fallo devuelve values, así que los inputs se vuelven a rellenar tras un error.
  • Se reenvía el FormData original, honeypot incluido, sin el token. Ikiform puntúa un honeypot relleno y guarda el mensaje como spam.
  • 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.
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 widget como action de Svelte

src/lib/turnstile.ts
const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";

interface Api {
  render(container: HTMLElement, options: { sitekey: string; action: string }): string;
  reset(widgetId: string): void;
  remove(widgetId: string): void;
}

let loading: Promise<Api> | undefined;

function load() {
  loading ??= new Promise<Api>((resolve, reject) => {
    const script = document.createElement("script");
    script.src = SCRIPT;
    script.async = true;
    script.onload = () => {
      const api = (window as Window & { turnstile?: Api }).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 turnstile(node: HTMLElement, sitekey: string) {
  let api: Api | undefined;
  load().then((loaded) => {
    api = loaded;
    node.dataset.widgetId = loaded.render(node, { sitekey, action: "contact" });
  });
  return {
    destroy() {
      if (node.dataset.widgetId) api?.remove(node.dataset.widgetId);
    },
  };
}

export async function resetTurnstile(node: HTMLElement | undefined) {
  const id = node?.dataset.widgetId;
  if (id) (await load()).reset(id);
}

Una action de Svelte es el lugar natural para un widget de terceros: se ejecuta cuando se monta el elemento, y destroy se ejecuta cuando sale. El ID del widget vive en el elemento, así que la página puede reiniciar exactamente ese widget.

La página

src/routes/contact/+page.svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import { PUBLIC_TURNSTILE_SITE_KEY } from "$app/env/public";
  import { resetTurnstile, turnstile } from "#lib/turnstile.js";
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
  let box = $state<HTMLDivElement>();
  let sending = $state(false);
</script>

{#if form?.sent}
  <p role="status">Thanks. We'll reply by email.</p>
{:else}
  <form
    method="POST"
    use:enhance={() => {
      sending = true;
      return async ({ update }) => {
        await update({ reset: false });
        sending = false;
        await resetTurnstile(box);
      };
    }}
  >
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required maxlength="120" value={form?.values?.name ?? ""} />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required value={form?.values?.email ?? ""} />
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="5" required value={form?.values?.message ?? ""}></textarea>
    <div class="hp" aria-hidden="true">
      <label for="hp-field">Leave this empty</label>
      <input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
    </div>
    <div bind:this={box} use:turnstile={PUBLIC_TURNSTILE_SITE_KEY}></div>
    {#if form?.error}
      <p role="alert">{form.error}</p>
    {/if}
    <button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
  </form>
{/if}
  • update({ reset: false }) aplica el resultado y conserva lo que escribió el visitante. Por defecto se vaciaría el formulario, como hace un navegador.
  • resetTurnstile se ejecuta después de cada respuesta, con éxito o no. El token usado desaparece y hay uno nuevo listo.
  • Sin JavaScript, el formulario se envía igualmente y la action se ejecuta, pero no se renderiza ningún widget, así que siteverify lo rechaza con un mensaje claro.
  • Una página con form actions no puede prerenderizarse, así que esta ruta siempre se renderiza en el servidor. Las demás páginas pueden seguir prerenderizadas.
  • La clase .hp oculta el honeypot fuera de pantalla, como en la guía del honeypot.

Pruébalo en local

Con vite dev no hay adapter delante de la app, así que getClientAddress() devuelve tu dirección de loopback y todas las pestañas locales comparten un mismo bucket. Sirve para comprobar el mensaje, no la configuración del proxy. Para probar eso, haz el build con adapter-node y ejecuta el resultado con las mismas variables que usarás en producción:

Terminal
npm run build
ADDRESS_HEADER=X-Forwarded-For XFF_DEPTH=1 node build

Envía varias veces con distintos valores de X-Forwarded-For. Con XFF_DEPTH=1, SvelteKit lee la dirección de más a la derecha, la que añadiría tu proxy, así que un valor que el cliente invente a la izquierda no cambia nada.

Despliegue

En tu propio servidor con adapter-node detrás de un proxy, indica a SvelteKit dónde está la IP real:

Entorno
ADDRESS_HEADER=X-Forwarded-For
XFF_DEPTH=1

La documentación de SvelteKit advierte que las cabeceras son fáciles de falsificar y que leer la dirección de más a la izquierda de X-Forwarded-For es el error clásico. XFF_DEPTH cuenta los proxies de confianza desde la derecha, así que ponle el número de proxies que tienes, y asegúrate de que no se pueda llegar a la app sin pasar por ellos. SvelteKit 3 eliminó la variable ORIGIN de adapter-node. Si tu proxy no pasa el host original, define paths.origin en las opciones de sveltekit() para que la comprobación de formularios propia de SvelteKit conozca tu URL pública.

AdapterIP del clienteSecretos
adapter-nodeADDRESS_HEADER y XFF_DEPTHEntorno del proceso
adapter-vercelAutomáticoVariables de entorno del proyecto
adapter-netlifyAutomáticoVariables de entorno del sitio
adapter-cloudflareAutomáticowrangler secret put

Deja que Ikiform haga las comprobaciones

Para no gestionar nada de esto tú, elimina +page.server.ts y apunta el action del formulario a tu endpoint de Ikiform, sin use:enhance. La página puede entonces prerenderizarse como el resto de un sitio estático.

Pon el Spam check del formulario en Ikiform, para que un envío sin token reciba una breve página de verificación en ikiform.com que ejecuta Turnstile y reenvía los campos. 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, así que un falso positivo se arregla con un clic para restaurarlo. La guía de configuración de SvelteKit cubre el endpoint y las claves.

Comprueba que funciona

  1. Ejecuta vite 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 un segundo mensaje sin recargar. Funciona, lo que demuestra que el reinicio del widget funcionó.
  3. Envía seis mensajes en un minuto. El sexto muestra cuántos segundos esperar.
  4. Detrás de tu proxy, registra getClientAddress() desde dos redes. Ves dos direcciones distintas, no la de tu proxy.
  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
Todos los visitantes comparten el límite de frecuencia en adapter-nodegetClientAddress() devuelve la dirección de tu proxy. Define ADDRESS_HEADER y XFF_DEPTH.
El segundo envío siempre falla la verificación humanaEl widget conservó su token usado. Llama a resetTurnstile en el callback de use:enhance después de update.
El build falla porque una página con actions está prerenderizadaLa ruta de contacto heredó prerender = true de un layout. Las páginas con actions no pueden prerenderizarse; pon export const prerender = false en su +page.server.ts.
La zona del widget está vacía y la consola muestra una infracción de CSPA script-src o a frame-src les falta https://challenges.cloudflare.com en csp.directives en vite.config.ts.

Preguntas

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