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 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.

Preet SutharFundador, Ikiform

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.

Escolhendo onde as verificações de spam rodam num site Astropáginadecontatosobdemandaadapter eprerender= falseAstroActionlimite,siteverify,repasseIkiformchavesecreta,guardaformulárioHTMLsimplessemadapter,semJavaScriptIkiformhoneypot,Turnstile,limitesuas próprias chavesPOST do formulárioJSONmanter estáticoPOSTEscolhendo onde as verificações de spam rodam num site Astropágina decontatosob demandaadapter e prerender =falseAstro Actionlimite, siteverify,repasseIkiformchave secreta, guardaformulárioHTMLsimplessem adapter,semJavaScriptIkiformhoneypot,Turnstile,limitesuas próprias chavesPOST do formulárioJSONmanter estático
  1. Decida como a página de contato renderiza.
  2. Com suas próprias chaves do Turnstile, a página renderiza sob demanda por meio de um adapter.
  3. O formulário envia a uma Astro Action, que limita por IP, valida o token e repassa os campos.
  4. O Ikiform guarda a mensagem e pontua o honeypot.
  5. 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.
Os dois caminhos barram os mesmos bots. O de cima dá a você seu próprio widget e seu próprio limite; o de baixo não publica código de servidor nenhum.

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

Arquivos
src/
  actions/
    index.ts
  lib/
    limit.ts
    turnstile.ts
  pages/
    contact.astro
astro.config.mjs
.env

Adicione 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:

.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
astro.config.mjs
import 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/server se recusa a carregar num script do cliente.
  • scriptDirective.resources substitui 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 com astro build e astro 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:

src/lib/limit.ts
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:

src/lib/limit.ts
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

src/lib/turnstile.ts
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 URLSearchParams nã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 em astro 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

src/actions/index.ts
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 Origin do visitante vai junto porque um fetch do lado do servidor não envia nenhum, e o Ikiform o lê para reconhecer testes gratuitos em localhost.
  • TOO_MANY_REQUESTS vira um 429 e FORBIDDEN um 403.
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.

A página

src/pages/contact.astro
---
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

HostAdapterOnde ficam os segredosLimite
Vercel@astrojs/vercelVariáveis de ambiente do projetoRedis
Netlify@astrojs/netlifyVariáveis de ambiente do siteRedis
Cloudflare@astrojs/cloudflarewrangler secret putBinding 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:

src/components/Contact.astro
---
---

<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

  1. Rode astro 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. Deixe o nome vazio depois de remover o atributo required nas devtools. O erro do campo aparece embaixo do input.
  3. Defina TURNSTILE_SECRET_KEY com o segredo que sempre falha e envie. A página mostra o erro da verificação humana.
  4. Envie seis vezes em um minuto. O sexto responde 429 com a mensagem de limite.
  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.
  6. Rode astro build e astro preview, abra a página e confira se o console não mostra violações de CSP.

Pegadinhas

ProblemaO que fazer
clientAddress não está disponível em páginas pré-renderizadasA página ou a action rodou em tempo de build. Adicione export const prerender = false à página de contato.
O resultado da action é sempre undefinedO 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 devA 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 personalizadosecurity.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 chegouEle 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

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