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.
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.
- A form action pede ao evento da requisição o endereço do cliente.
- O SvelteKit delega a resposta ao adapter com que você faz o deploy.
- O adapter-node usa o endereço remoto do socket, a menos que você defina ADDRESS_HEADER e XFF_DEPTH.
- Os adapters da Vercel, Netlify e Cloudflare leem o endereço que a plataforma fornece, então não há nada a configurar.
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
src/
env.ts
lib/
turnstile.ts
server/
spam.ts
routes/
contact/
+page.server.ts
+page.svelte
vite.config.ts
.envPUBLIC_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_tokenimport { 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 tempublic: true, então é a única que$app/env/publicpode entregar ao navegador. Nada num diretórioserver, comosrc/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.
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
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.
resetvolta em milissegundos e vira segundos inteiros de espera. devde$app/envpula a verificação de hostname localmente, porque tokens de teste trazem um hostname fictício.- O siteverify responde
timeout-or-duplicatepara um token com mais de 300 segundos ou já usado, e é por isso que a página reinicia o widget.
A form action
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
FormDataoriginal é repassado, honeypot incluído, sem o token. O Ikiform pontua um honeypot preenchido e guarda a mensagem como spam. - O
Origindo visitante é repassado, porque um fetch do lado do servidor não envia nenhum e o Ikiform o lê para reconhecer testes gratuitos emlocalhost.
IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.O widget como uma Svelte action
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
<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.resetTurnstileroda 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
.hpesconde 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:
npm run build
ADDRESS_HEADER=X-Forwarded-For XFF_DEPTH=1 node buildEnvie 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:
ADDRESS_HEADER=X-Forwarded-For
XFF_DEPTH=1A 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.
| Adapter | IP do cliente | Segredos |
|---|---|---|
adapter-node | ADDRESS_HEADER e XFF_DEPTH | Ambiente do processo |
adapter-vercel | Automático | Variáveis de ambiente do projeto |
adapter-netlify | Automático | Variáveis de ambiente do site |
adapter-cloudflare | Automático | wrangler 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
- Rode
vite devcom as chaves de teste e envie uma mensagem. Ela aparece em Submissions. Um workspace sem plano aceita 50 testes por mês a partir delocalhost. - Envie uma segunda mensagem sem recarregar. Ela funciona, o que mostra que o reinício do widget deu certo.
- Envie seis mensagens em um minuto. A sexta mostra quantos segundos esperar.
- Atrás do seu proxy, registre
getClientAddress()a partir de duas redes. Você vê dois endereços diferentes, não o do seu proxy. - 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
| Problema | O que fazer |
|---|---|
Todos os visitantes são limitados juntos no adapter-node | getClientAddress() retorna o endereço do seu proxy. Defina ADDRESS_HEADER e XFF_DEPTH. |
| O segundo envio sempre falha na verificação humana | O 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é-renderizada | A 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 CSP | Falta https://challenges.cloudflare.com em script-src ou frame-src em csp.directives no vite.config.ts. |
Perguntas
Chame getClientAddress() no evento da requisição numa form action, função load ou hook. O adapter o fornece. No adapter-node atrás de um proxy, defina ADDRESS_HEADER e XFF_DEPTH, senão você recebe o endereço do proxy.
O use:enhance não recarrega a página, então o widget mantém o token que já usou. Reinicie o widget no callback do enhance após cada resposta.
Sim. Adicione https://challenges.cloudflare.com a script-src e frame-src em csp.directives, que o SvelteKit 3 recebe nas opções do plugin sveltekit() em vite.config.ts. Com mode auto, o SvelteKit adiciona nonces ou hashes aos próprios scripts.
Não, não há plano gratuito. Testar é grátis: você pode enviar 50 envios de teste por mês a partir de localhost antes de pagar. Para entrar no ar, o Pro começa em US$ 6/mês para 500 envios, ou você pode pagar uma vez: 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.