Acabe com o spam no formulário de contato

Acabe com o spam no formulário de contato8 min de leitura

Proteção contra spam em formulários Nuxt com Turnstile

Proteja um formulário Nuxt contra spam: server route do Nitro com Turnstile, limite por IP no Nitro storage, segredos no runtimeConfig e CSP via routeRules.

Preet SutharFundador, Ikiform

Envie o formulário com $fetch a uma server route que conta tentativas por IP do cliente no Nitro storage, valida o token do Turnstile com o siteverify e repassa os campos com o honeypot. Guarde os segredos no runtimeConfig privado, definidos por variáveis de ambiente NUXT_ em tempo de execução. Envie a CSP da página de contato pelas routeRules, permitindo challenges.cloudflare.com para o script e o frame dele.

O que muda no Nuxt

O servidor do Nuxt é o Nitro, e o Nitro traz duas coisas de que uma verificação de spam precisa. As server routes em server/api são endpoints simples com o $fetch já ligado a elas, e useStorage() dá um armazenamento chave-valor cujo backend você escolhe por ambiente: memória em desenvolvimento, Redis ou outro driver em produção. Isso faz do limite de taxa uma mudança de configuração, e não uma dependência.

Dois hábitos do Nuxt causam problemas aqui. getRequestIP(event, { xForwardedFor: true }) retorna a primeira entrada de X-Forwarded-For, que a própria documentação do h3 chama de input do cliente. E arquivos .env não são lidos em produção, então os segredos precisam ser definidos como variáveis de ambiente da plataforma.

Um envio de contato pela server route do Nuxt e pelo Nitro storageContactForm.vue$fetchcomotokenclientIp(event)cabeçalhodoruntimeConfiguseStoragecontaesteminutositeverifyactionehostIkiformguarda,pontuahoneypot429segundosrestantesnajanelaPOST /api/contactmenos de 5humanoacimaUm envio de contato pela server route do Nuxt e pelo Nitro storageContactForm.vue$fetch com o tokenclientIp(event)cabeçalho doruntimeConfiguseStorageconta esteminutositeverifyaction e hostIkiformguarda, pontuahoneypot429segundosrestantes najanelaPOST /api/contactmenos de 5humanoacima
  1. O componente envia os campos e o token do Turnstile para /api/contact com $fetch.
  2. A server route lê o IP do cliente do cabeçalho indicado no runtimeConfig.
  3. Ela conta os envios do IP no minuto atual no Nitro storage. Acima de cinco, lança um 429 com os segundos restantes.
  4. Dentro do limite, ela valida o token com o siteverify, conferindo a action e o host.
  5. Depois repassa os campos ao Ikiform, que os guarda e pontua o honeypot.
Em desenvolvimento, o storage é montado em memória. Em produção, o mesmo código fala com o Redis, porque o nuxt.config monta outro driver.

Configuração do projeto

Arquivos
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" },
      },
    },
  },
});
  • Toda chave do runtimeConfig pode ser sobrescrita em tempo de execução por uma variável NUXT_: turnstileSecretKey por NUXT_TURNSTILE_SECRET_KEY, public.turnstileSiteKey por NUXT_PUBLIC_TURNSTILE_SITE_KEY. Chaves fora de public nunca chegam ao navegador.
  • routeRules envia a CSP só em /contact. O Nuxt embute o payload e os scripts de hidratação inline, então esta política mantém 'unsafe-inline'; o módulo da comunidade nuxt-security pode gerar nonces se você precisar de uma política mais rígida.
  • O driver upstash só é montado em produção e lê UPSTASH_REDIS_REST_URL e UPSTASH_REDIS_REST_TOKEN do ambiente. Ele precisa do pacote @upstash/redis, então instale-o com npm install @upstash/redis.

Utilitários de servidor

Arquivos em server/utils são importados automaticamente em toda server route:

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 lê exatamente um cabeçalho, aquele que seu host sobrescreve. Defina NUXT_CLIENT_IP_HEADER como x-forwarded-for na Vercel, x-nf-client-connection-ip na Netlify e cf-connecting-ip na Cloudflare.
  • O contador é uma janela fixa no Nitro storage. Ler e depois escrever não é atômico, então dois envios no mesmo instante podem passar abaixo do limite; para um formulário de contato isso é aceitável. As chaves não expiram sozinhas, então dê ao banco do Redis uma política de remoção.
  • $fetch envia o corpo do siteverify como JSON, que o siteverify aceita. A verificação de host usa getRequestHost e é pulada em desenvolvimento, porque tokens de teste trazem um hostname fictício.

A 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 };
});
  • O sufixo .post.ts faz o Nitro responder só a POST. Qualquer outra coisa recebe 405.
  • O limite roda antes de readBody, então uma enxurrada nunca custa a leitura do corpo nem uma chamada ao siteverify.
  • Cada falha lança createError. O corpo JSON traz message, que o componente mostra.
  • O Origin do visitante é repassado, porque um fetch do lado do servidor não envia nenhum e o Ikiform o lê para reconhecer testes gratuitos em localhost.
  • O honeypot _gotcha vai junto sem alteração, então o Ikiform guarda um preenchido como spam em vez de descartá-lo.
As versões do lado do servidor enviam com uma chave secreta, então o Ikiform pula Allowed domains e a verificação humana dele. Todo envio passa a vir do IP do seu servidor, e o limite Standard de 5 por minuto por visitante conta todos os seus visitantes juntos. Escolha o limite de taxa Relaxed ou Custom para um formulário enviado por um servidor e mantenha IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.

O composable e o formulário

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>
  • O composable só roda em onMounted, então a renderização no servidor nunca mexe em window.
  • O botão espera um token, e reset() pede um novo após cada falha, porque um token funciona uma vez e dura 300 segundos.
  • Este formulário precisa de JavaScript. Para uma versão que envia sem ele, veja a próxima seção.
  • A classe .hp esconde o honeypot fora da tela, como no guia do honeypot.

Teste localmente

Em desenvolvimento, o ratelimit é montado em memória, então a contagem zera quando o nuxt dev reinicia. Você pode ver o limite funcionar sem navegador. Envios sem token falham no siteverify com 403, e cada um ainda conta:

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

As cinco primeiras linhas imprimem 403 e a sexta imprime 429. Localmente nenhum proxy define o cabeçalho de IP, então toda requisição conta como unknown; isso é esperado, e é por isso que o cabeçalho fica na configuração e não no código.

Deploy

HostNUXT_CLIENT_IP_HEADERDriver de storage
Vercelx-forwarded-forupstash, do Vercel Marketplace
Netlifyx-nf-client-connection-ipupstash ou redis
Cloudflarecf-connecting-ipupstash; o Workers KV tem consistência eventual, então as contagens atrasam
Servidor NodeO cabeçalho que seu proxy defineredis, ou memória numa única instância

Defina todas as variáveis NUXT_ no painel do host. O Nuxt as lê quando o servidor inicia, então mudar uma chave exige reiniciar, não refazer o build. Adicione cada hostname de produção e de preview ao widget do Turnstile.

Deixe o Ikiform rodar as verificações

Sem a server route, a página de contato pode ser pré-renderizada com o resto de um site nuxt generate. Aponte um formulário simples para o seu endpoint do Ikiform com method="post" e action com a URL do endpoint, e apague server/api/contact.post.ts e o composable.

Defina o Spam check do formulário como Ikiform. Um envio sem token recebe uma breve página de verificação em ikiform.com que roda o Turnstile e repassa os campos, então sua CSP não precisa de entradas da Cloudflare. Escolha o Rate limit Strict, 2 a cada 10 minutos por visitante, e liste seu domínio em Allowed domains. O Ikiform guarda o spam barrado por 30 dias com os motivos pelos quais foi barrado. O guia de configuração do Nuxt cobre o endpoint e as chaves.

Confira se funciona

  1. Rode nuxt dev com as chaves de teste e envie uma mensagem. Ela aparece em Submissions. Um workspace sem plano aceita 50 testes por mês a partir de localhost.
  2. Envie seis mensagens em um minuto. A sexta mostra quantos segundos faltam.
  3. Defina NUXT_TURNSTILE_SECRET_KEY com o segredo que sempre falha e reinicie. Todo envio mostra o erro da verificação humana.
  4. Requisite /contact com curl -I depois do nuxt build. A resposta traz sua Content-Security-Policy.
  5. Envie com o honeypot preenchido, _gotcha=bot. Ele ainda recebe uma resposta de sucesso, mas o envio é guardado como spam e não dispara e-mail nem webhook.

Pegadinhas

ProblemaO que fazer
Os segredos ficam vazios em produçãoO .env não é lido em tempo de execução em produção. Defina NUXT_TURNSTILE_SECRET_KEY e as outras no ambiente do host.
O limite zera a cada deploy ou instânciaO storage ainda está montado em memória. Confira se $production.nitro.storage monta um driver compartilhado e se as credenciais dele estão definidas.
Um script passa pelo limite mudando cabeçalhosVocê lê getRequestIP com xForwardedFor: true num host que não sobrescreve esse cabeçalho. Leia o cabeçalho do próprio host por clientIp.
window is not defined durante a renderização no servidorCódigo do Turnstile rodou fora de onMounted. Mantenha todo acesso a window dentro dos lifecycle hooks do composable.

Perguntas

Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.