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 SvelteKit com Turnstile

Proteja um formulário SvelteKit contra spam: form action com getClientAddress, limite por IP, Turnstile com use:enhance e uma CSP que o permita.

Preet SutharFundador, Ikiform

Faça as verificações numa form action: limite por getClientAddress(), valide o token do Turnstile com o siteverify e depois repasse os campos com o honeypot. Renderize o widget com uma Svelte action e reinicie-o no callback do use:enhance, porque cada token funciona uma vez. Permita challenges.cloudflare.com na opção csp do plugin sveltekit() e, no adapter-node, diga ao SvelteKit qual cabeçalho do proxy traz o IP real.

O que muda no SvelteKit

O SvelteKit entrega o IP do visitante diretamente: getClientAddress() no evento da requisição. De onde vem esse valor depende do adapter, e no adapter-node o padrão está errado atrás de qualquer proxy. Acerte isso e o resto das verificações se encaixa direitinho numa form action.

De onde getClientAddress() tira o IP do visitante em cada adapterform actionchamagetClientAddress()seu adapteradapter-nodeendereço do socket, ouADDRESS_HEADERadapter-vercelda borda da Verceladapter-netlifyda borda da Netlifyadapter-cloudflarede cf-connecting-ipDe onde getClientAddress() tira o IP do visitante em cada adapterform actionchamagetClientAddress()seu adapteradapter-nodeendereço do socket,ouADDRESS_HEADERadapter-vercelda borda da Verceladapter-netlifyda borda da Netlifyadapter-cloudflaredecf-connecting-ip
  1. A form action pede ao evento da requisição o endereço do cliente.
  2. O SvelteKit delega a resposta ao adapter com que você faz o deploy.
  3. O adapter-node usa o endereço remoto do socket, a menos que você defina ADDRESS_HEADER e XFF_DEPTH.
  4. Os adapters da Vercel, Netlify e Cloudflare leem o endereço que a plataforma fornece, então não há nada a configurar.
Atrás do nginx ou de um load balancer, o adapter-node vê o endereço do proxy para todo visitante até você definir ADDRESS_HEADER, e um endereço compartilhado transforma um limite por IP num limite do site inteiro.

O outro detalhe do SvelteKit é o use:enhance. Ele envia sem recarregar a página, o que é melhor para as pessoas, mas significa que o Turnstile nunca renderiza de novo sozinho. O widget precisa ser reiniciado à mão após cada resposta.

Configuração do projeto

Arquivos
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: {},
});
  • O SvelteKit 3 lê as variáveis que você declara em src/env.ts. Só a site key tem public: true, então é a única que $app/env/public pode entregar ao navegador. Nada num diretório server, como src/lib/server, pode ser importado por código do cliente; o SvelteKit falha o build se você tentar.
  • Variáveis declaradas são lidas quando a aplicação inicia, a menos que você defina static: true, então uma chave trocada exige reiniciar, não refazer o build. Os módulos $env/* ainda funcionam, mas estão obsoletos no SvelteKit 3.
  • As chaves de teste da Cloudflare passam em qualquer host, inclusive 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"],
        },
      },
    }),
  ],
});

O SvelteKit 3 recebe as opções em vite.config.ts, passadas ao plugin sveltekit(); o svelte.config.js não é mais lido. Com mode: "auto", o SvelteKit adiciona nonces aos próprios scripts inline em páginas renderizadas no servidor e hashes nas pré-renderizadas, então a política não precisa de 'unsafe-inline'. O Turnstile carrega do host dele, que script-src permite, e desenha um iframe que frame-src permite.

Os helpers só do 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;
  }
}
  • O limite é uma janela deslizante no Redis, compartilhada por todas as instâncias do servidor, cinco mensagens por minuto por IP. reset volta em milissegundos e vira segundos inteiros de espera.
  • dev de $app/env pula a verificação de hostname localmente, porque tokens de teste trazem um hostname fictício.
  • O siteverify responde timeout-or-duplicate para um token com mais de 300 segundos ou já usado, e é por isso que a página reinicia o widget.

A 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;
  • O limite roda antes de o corpo ser interpretado. fail(429) mantém o visitante na página com a mensagem.
  • Toda outra falha retorna values, então os campos voltam preenchidos após um erro.
  • O FormData original é repassado, honeypot incluído, sem o token. O Ikiform pontua um honeypot preenchido e guarda a mensagem como spam.
  • 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.
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 widget como uma Svelte action

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);
}

Uma Svelte action é o lugar natural para um widget de terceiros: ela roda quando o elemento é montado, e destroy roda quando ele sai. O ID do widget fica no elemento, então a página pode reiniciar exatamente aquele widget.

A 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 o resultado e mantém o que o visitante digitou. O padrão limparia o formulário, como faz um navegador.
  • resetTurnstile roda após cada resposta, com sucesso ou não. O token usado some e um novo fica pronto.
  • Sem JavaScript, o formulário ainda envia e a action ainda roda, mas nenhum widget é renderizado, então o siteverify o recusa com uma mensagem clara.
  • Uma página com form actions não pode ser pré-renderizada, então esta rota sempre renderiza no servidor. As outras páginas podem continuar pré-renderizadas.
  • A classe .hp esconde o honeypot fora da tela, como no guia do honeypot.

Teste localmente

Em vite dev não há adapter na frente da aplicação, então getClientAddress() retorna seu endereço de loopback e todas as abas locais compartilham um único bucket. Isso serve para conferir a mensagem, não a configuração do proxy. Para testar isso, faça o build com adapter-node e rode a saída com as mesmas variáveis que você vai usar em produção:

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

Envie algumas vezes com valores diferentes de X-Forwarded-For. Com XFF_DEPTH=1, o SvelteKit lê o endereço mais à direita, aquele que seu proxy adicionaria, então um valor que o cliente inventa à esquerda não muda nada.

Deploy

No seu próprio servidor com adapter-node atrás de um proxy, diga ao SvelteKit onde está o IP real:

Ambiente
ADDRESS_HEADER=X-Forwarded-For
XFF_DEPTH=1

A documentação do SvelteKit avisa que cabeçalhos são fáceis de falsificar e que ler o endereço mais à esquerda de X-Forwarded-For é o erro clássico. XFF_DEPTH conta proxies confiáveis a partir da direita, então defina-o com o número de proxies que você roda e garanta que a aplicação não possa ser acessada contornando-os. O SvelteKit 3 removeu a variável ORIGIN do adapter-node. Se seu proxy não repassa o host original, defina paths.origin nas opções do sveltekit() para que a verificação de formulário do próprio SvelteKit conheça sua URL pública.

AdapterIP do clienteSegredos
adapter-nodeADDRESS_HEADER e XFF_DEPTHAmbiente do processo
adapter-vercelAutomáticoVariáveis de ambiente do projeto
adapter-netlifyAutomáticoVariáveis de ambiente do site
adapter-cloudflareAutomáticowrangler secret put

Deixe o Ikiform rodar as verificações

Para não rodar nada disso você mesmo, apague +page.server.ts e aponte o action do formulário para o seu endpoint do Ikiform, sem use:enhance. Aí a página pode ser pré-renderizada como o resto de um site estático.

Defina o Spam check do formulário como Ikiform, para que um envio sem token receba uma breve página de verificação em ikiform.com que roda o Turnstile e repassa os campos. 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, então um falso positivo se resolve com um clique para restaurar. O guia de configuração do SvelteKit cobre o endpoint e as chaves.

Confira se funciona

  1. Rode vite 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 uma segunda mensagem sem recarregar. Ela funciona, o que mostra que o reinício do widget deu certo.
  3. Envie seis mensagens em um minuto. A sexta mostra quantos segundos esperar.
  4. Atrás do seu proxy, registre getClientAddress() a partir de duas redes. Você vê dois endereços diferentes, não o do seu proxy.
  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
Todos os visitantes são limitados juntos no adapter-nodegetClientAddress() retorna o endereço do seu proxy. Defina ADDRESS_HEADER e XFF_DEPTH.
O segundo envio sempre falha na verificação humanaO widget manteve o token já usado. Chame resetTurnstile no callback do use:enhance depois de update.
O build falha porque uma página com actions está pré-renderizadaA rota de contato herdou prerender = true de um layout. Páginas com actions não podem ser pré-renderizadas; defina export const prerender = false no +page.server.ts dela.
A área do widget fica vazia e o console aponta uma violação de CSPFalta https://challenges.cloudflare.com em script-src ou frame-src em csp.directives no vite.config.ts.

Perguntas

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