Acabe com o spam no formulário de contato8 min de leitura
Proteção contra spam em formulários Astro com Turnstile
Proteja um formulário Astro contra spam com uma Action: honeypot, Turnstile validado com segredos do astro:env, limite por IP e a CSP embutida do Astro.
Coloque as verificações numa Astro Action para a qual o formulário envia sem JavaScript: limite por context.clientAddress, valide o token do Turnstile com um segredo de astro:env/server e depois repasse os campos com o honeypot. A página de contato precisa renderizar sob demanda, então adicione um adapter e prerender = false nela. Se preferir manter o site todo estático, envie o formulário simples ao Ikiform e deixe que ele rode as mesmas verificações.
O que muda no Astro
O Astro gera HTML estático por padrão, e HTML estático não guarda segredos. Um segredo do Turnstile, uma chave secreta do Ikiform e um contador de limite de taxa precisam de código que rode a cada requisição, então a primeira decisão num site Astro é se a página de contato vai renderizar sob demanda.
- Decida como a página de contato renderiza.
- Com suas próprias chaves do Turnstile, a página renderiza sob demanda por meio de um adapter.
- O formulário envia a uma Astro Action, que limita por IP, valida o token e repassa os campos.
- O Ikiform guarda a mensagem e pontua o honeypot.
- Para manter o site estático, o formulário simples envia direto ao Ikiform, que roda o honeypot, a verificação Turnstile gerenciada e o limite de taxa.
O Astro também confere por padrão a origem dos envios de formulário para páginas sob demanda, via security.checkOrigin. Isso impede que a página de outro site envie para a sua. Não impede um script, que pode mandar os cabeçalhos que quiser, então as verificações abaixo continuam valendo.
Configuração do projeto
src/
actions/
index.ts
lib/
limit.ts
turnstile.ts
pages/
contact.astro
astro.config.mjs
.envAdicione um adapter com npx astro add vercel, netlify ou cloudflare. Declare cada variável no schema astro:env, para que um segredo ausente falhe na importação e um segredo nunca possa ser importado em código do cliente:
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_tokenimport vercel from "@astrojs/vercel";
import { defineConfig, envField } from "astro/config";
export default defineConfig({
adapter: vercel(),
env: {
schema: {
PUBLIC_TURNSTILE_SITE_KEY: envField.string({ context: "client", access: "public" }),
TURNSTILE_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
UPSTASH_REDIS_REST_URL: envField.string({ context: "server", access: "secret" }),
UPSTASH_REDIS_REST_TOKEN: envField.string({ context: "server", access: "secret" }),
},
},
security: {
csp: {
scriptDirective: {
resources: ["'self'", "https://challenges.cloudflare.com"],
},
directives: ["frame-src https://challenges.cloudflare.com"],
},
},
});access: "secret"mantém um valor fora do bundle.astro:env/serverse recusa a carregar num script do cliente.scriptDirective.resourcessubstitui os padrões do Astro, então liste'self'de novo ao lado da Cloudflare. O Astro continua gerando hashes dos próprios scripts.- A CSP não roda em
astro dev. Confira comastro buildeastro preview.
Um limite por IP
Numa Action, context.clientAddress é o IP do visitante como o adapter o recebe da plataforma, então você nunca precisa interpretar X-Forwarded-For. Ele só existe em rotas sob demanda e lança erro nas pré-renderizadas. Este contador mantém uma chave do Redis por IP por minuto, então funciona entre instâncias serverless:
import { Redis } from "@upstash/redis";
import { UPSTASH_REDIS_REST_TOKEN, UPSTASH_REDIS_REST_URL } from "astro:env/server";
const redis = new Redis({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN });
export async function overLimit(ip: string, limit = 5, windowSeconds = 60) {
const key = `contact:${ip}:${Math.floor(Date.now() / 1000 / windowSeconds)}`;
const [count] = await redis.multi().incr(key).expire(key, windowSeconds).exec<[number, number]>();
return count > limit;
}Na Cloudflare, o Astro 7 lê bindings de cloudflare:workers em vez de Astro.locals.runtime. Um binding de rate limiting do Workers declarado em wrangler.jsonc substitui o Redis sem chamada de rede, ao custo de uma contagem aproximada por localidade:
import { env } from "cloudflare:workers";
export async function overLimit(ip: string) {
const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
return !success;
}Valide o token
import { TURNSTILE_SECRET_KEY } from "astro:env/server";
export async function isHuman(token: string, ip: string, host: string) {
const body = new URLSearchParams({ secret: TURNSTILE_SECRET_KEY, response: token, remoteip: ip });
try {
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body });
const verdict = (await response.json()) as { success: boolean; hostname?: string; action?: string };
if (!verdict.success || verdict.action !== "contact") return false;
return import.meta.env.DEV || verdict.hostname === host;
} catch {
return false;
}
}- O siteverify aceita corpos form-encoded, então
URLSearchParamsnão precisa de cabeçalhos. - O hostname é comparado com
context.url.hostname, o host para onde o visitante realmente enviou. Tokens de teste trazem um hostname fictício, então a verificação é pulada emastro dev. - Um token vale por 300 segundos e funciona uma vez. Um envio de formulário simples recarrega a página, e o Turnstile emite um novo a cada vez.
A Action
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";
import { overLimit } from "../lib/limit";
import { isHuman } from "../lib/turnstile";
export const server = {
sendMessage: defineAction({
accept: "form",
input: z.object({
name: z.string().trim().min(1, "Tell us your name.").max(120),
email: z.email("Enter an address we can reply to."),
message: z.string().trim().min(10, "A few words more, please.").max(5000),
_gotcha: z.string().optional(),
"cf-turnstile-response": z.string().max(2048).optional(),
}),
handler: async (input, context) => {
const ip = context.clientAddress;
if (await overLimit(ip)) {
throw new ActionError({ code: "TOO_MANY_REQUESTS", message: "Too many messages. Try again in a minute." });
}
if (!(await isHuman(input["cf-turnstile-response"] ?? "", ip, context.url.hostname))) {
throw new ActionError({ code: "FORBIDDEN", message: "We couldn't confirm you're human. Try again." });
}
const { "cf-turnstile-response": _token, ...fields } = input;
const origin = context.request.headers.get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(fields),
});
if (!response.ok) throw new ActionError({ code: "BAD_GATEWAY", message: "Your message didn't send. Try again." });
return { sent: true };
},
}),
};- O Zod roda antes do handler, então um envio malformado não custa nenhuma chamada ao Redis. Os erros de campo voltam à página por
isInputError. - O honeypot
_gotchaé opcional no schema e repassado sem alteração. O Ikiform pontua um preenchido como spam, então você ainda pode revisá-lo. - O token é removido antes do repasse. O Ikiform não tem uso para um token que você já validou.
- O
Origindo visitante vai junto porque um fetch do lado do servidor não envia nenhum, e o Ikiform o lê para reconhecer testes gratuitos emlocalhost. TOO_MANY_REQUESTSvira um429eFORBIDDENum403.
IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.A página
---
import { actions, isInputError } from "astro:actions";
import { PUBLIC_TURNSTILE_SITE_KEY } from "astro:env/client";
export const prerender = false;
const result = Astro.getActionResult(actions.sendMessage);
const fields = result?.error && isInputError(result.error) ? result.error.fields : {};
---
<main>
<h1>Contact us</h1>
{result?.data?.sent ? (
<p role="status">Thanks. We'll reply by email.</p>
) : (
<form method="POST" action={actions.sendMessage}>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required maxlength="120" />
{fields.name && <p class="field-error">{fields.name[0]}</p>}
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
{fields.email && <p class="field-error">{fields.email[0]}</p>}
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required minlength="10"></textarea>
{fields.message && <p class="field-error">{fields.message[0]}</p>}
<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 class="cf-turnstile" data-sitekey={PUBLIC_TURNSTILE_SITE_KEY} data-action="contact"></div>
{result?.error && !isInputError(result.error) && <p role="alert">{result.error.message}</p>}
<button>Send message</button>
</form>
)}
</main>
<script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<style>
.hp {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}
</style>O formulário funciona sem nenhum JavaScript no cliente. action={actions.sendMessage} envia para a mesma página com o nome da action na query string; o Astro roda a action e Astro.getActionResult() lê o resultado durante a renderização. Só esta página renderiza sob demanda. O resto do site continua estático.
- Atualizar a página após um envio que falhou pede para reenviar o formulário. A documentação do Astro sugere o padrão POST, redirect, GET se isso incomodar, levando o resultado numa sessão.
- O script do Turnstile usa
is:inline, então o Astro não mexe nele. Ele precisa ser carregado da URL da própria Cloudflare. - Chamar a action a partir de um script do cliente numa página estática também funciona, mas aí você mesmo precisa chamar
turnstile.reset()após cada tentativa.
Deploy
| Host | Adapter | Onde ficam os segredos | Limite |
|---|---|---|---|
| Vercel | @astrojs/vercel | Variáveis de ambiente do projeto | Redis |
| Netlify | @astrojs/netlify | Variáveis de ambiente do site | Redis |
| Cloudflare | @astrojs/cloudflare | wrangler secret put | Binding de rate limiting do Workers |
Adicione seu hostname de produção ao widget do Turnstile antes do deploy, e também os hostnames de preview se testar neles.
Mantenha estático e deixe o Ikiform verificar
A maioria dos sites Astro é estática, e adicionar um adapter só por causa de um formulário de contato tem um custo real. Sem um, envie o formulário direto ao seu endpoint do Ikiform:
---
---
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required minlength="10"></textarea>
<div class="hp" aria-hidden="true">
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
</div>
<button>Send message</button>
</form>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 a página não precisa de script, chaves nem mudança na CSP. Escolha o Rate limit Strict, 2 a cada 10 minutos por visitante, e adicione seu domínio em Allowed domains. O spam recebe a mesma resposta que uma mensagem real e fica guardado por 30 dias para você resgatar um falso positivo. O guia de configuração do Astro mostra o resto.
Confira se funciona
- Rode
astro 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. - Deixe o nome vazio depois de remover o atributo
requirednas devtools. O erro do campo aparece embaixo do input. - Defina
TURNSTILE_SECRET_KEYcom o segredo que sempre falha e envie. A página mostra o erro da verificação humana. - Envie seis vezes em um minuto. O sexto responde
429com a mensagem de limite. - 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. - Rode
astro buildeastro preview, abra a página e confira se o console não mostra violações de CSP.
Pegadinhas
| Problema | O que fazer |
|---|---|
clientAddress não está disponível em páginas pré-renderizadas | A página ou a action rodou em tempo de build. Adicione export const prerender = false à página de contato. |
| O resultado da action é sempre undefined | O formulário enviou para uma página pré-renderizada. Form actions só rodam em páginas renderizadas sob demanda. |
| O widget fica em branco em produção, mas funciona em dev | A CSP o bloqueia. scriptDirective.resources precisa listar https://challenges.cloudflare.com, e frame-src também precisa permiti-lo. |
403 sem mensagem em todo envio a partir de um domínio personalizado | security.checkOrigin viu uma origem diferente, muitas vezes um proxy reescrevendo o host. Garanta que o cabeçalho host que chega ao Astro bata com o domínio da página. |
| O envio está no painel, mas nenhum e-mail ou webhook chegou | Ele foi guardado como spam. Spam não dispara nada. Abra-o no filtro Spam para ver os motivos e marque-o como não spam para disparar o e-mail e os webhooks. |
Perguntas
Não como form action. Um formulário que envia a uma action precisa da página renderizada sob demanda por meio de um adapter. Num site totalmente estático, envie o formulário a um backend de formulários como o Ikiform.
Use context.clientAddress numa Action ou Astro.clientAddress numa página. O adapter o preenche a partir da plataforma. Ele só existe em rotas renderizadas sob demanda.
Sim, com security.csp, estável desde o Astro 6. Adicione https://challenges.cloudflare.com a scriptDirective.resources ao lado de 'self', e adicione uma diretiva frame-src para o mesmo host. Teste com um build de produção, já que ela não roda em dev.
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.