Acabe com o spam no formulário de contato6 min de leitura
Como limitar a taxa de envios de formulário
Limite a taxa de envios por visitante com um armazenamento compartilhado, responda 429 com Retry-After e evite bloquear escritórios que compartilham um IP.
Conte os envios por IP do visitante num armazenamento que todas as instâncias do servidor compartilham, como o Redis, permita uma pequena rajada e responda 429 com um cabeçalho Retry-After quando a contagem acabar. Um contador em memória só funciona num servidor único de longa duração, nunca em hosts serverless. Defina o limite pelo jeito como pessoas reais usam o formulário e deixe folga para escritórios e eventos em que muita gente compartilha um IP.
O que os limites de taxa têm de diferente
Um limite de taxa não distingue spam de mensagem legítima. Ele barra volume: um script enviando em loop, um bot circulando pelo seu formulário ou uma pessoa martelando o botão. Isso o torna a camada mais barata, já que roda antes de você ler o corpo, e a mais bruta, já que não distingue um atacante de um escritório movimentado. Três coisas decidem se ele ajuda ou atrapalha: onde fica a contagem, pelo que você conta e com que rapidez a cota volta.
- Um visitante manda 3 envios em 10 segundos. Todos são aceitos.
- O seguinte é recusado com 429 e um cabeçalho Retry-After dizendo quanto esperar.
- Depois disso, mais um envio é permitido a cada 12 segundos.
- Após um minuto inteiro de silêncio, a cota volta por completo.
Onde fica a contagem
| Armazenamento | Funciona em | Cuidado com |
|---|---|---|
Um Map em memória | Um servidor único de longa duração | Some ao reiniciar. Cada instância serverless tem seu próprio map, então o limite real é desconhecido |
| Redis, como o Upstash | Qualquer lugar, inclusive serverless e edge | Uma chamada de rede por envio. Escolha uma região perto do seu servidor |
| Binding de rate limiting do Cloudflare Workers | Workers | Aproximado e contado por localidade da Cloudflare, só com períodos de 10 ou 60 segundos |
Um proxy reverso, como o limit_req do nginx | Servidores atrás do seu próprio proxy | Conta por proxy. Precisa do IP real do cliente |
O algoritmo importa menos que o armazenamento, mas molda o que os visitantes sentem. Uma janela fixa zera na virada do minuto, então um script pode mandar uma cota inteira no fim de um minuto e outra no começo do seguinte. Uma janela deslizante suaviza essa borda pesando a janela anterior. Um token bucket, ou o algoritmo parecido que o Ikiform usa, recarrega um envio por vez, o que deixa quem cometeu um erro de digitação enviar de novo poucos segundos depois sem abrir a porta para uma rajada. Para um formulário de contato, qualquer um deles funciona se os números combinarem com o uso real.
Os exemplos abaixo usam o @upstash/ratelimit do Upstash, porque funciona igual em todo framework e em hosts serverless. Defina UPSTASH_REDIS_REST_URL e UPSTASH_REDIS_REST_TOKEN e salve o helper uma vez:
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const contact = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, "60 s"),
prefix: "contact",
});
export async function allow(ip: string | null | undefined) {
const { success, reset } = await contact.limit(ip || "no-ip");
return { ok: success, retryAfter: Math.max(1, Math.ceil((reset - Date.now()) / 1000)) };
}reset é um horário Unix em milissegundos, então o helper o converte em segundos inteiros para o Retry-After. Envios sem IP compartilham um único bucket, o que impede que um cabeçalho ausente vire passe livre.
Aplique no seu framework
Um formulário HTML simples não tem servidor para contar nada. Envie-o ao Ikiform e escolha um preset no formulário; um visitante acima do limite vê uma breve página pedindo para ir mais devagar em vez de um erro.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<button>Send</button>
</form>Confira o limite no topo da server action, antes de qualquer outro trabalho.
"use server";
import { headers } from "next/headers";
import { allow } from "@/lib/limit";
export async function sendMessage(_previous: { message: string }, formData: FormData) {
const forwarded = (await headers()).get("x-forwarded-for");
const limit = await allow(forwarded?.split(",")[0]?.trim());
if (!limit.ok) return { message: `Too many messages. Try again in ${limit.retryAfter} seconds.` };
await saveMessage(Object.fromEntries(formData));
return { message: "Thanks. We'll reply by email." };
}context.clientAddress dá o IP do visitante numa página sob demanda. TOO_MANY_REQUESTS vira um 429.
import { ActionError, defineAction } from "astro:actions";
import { z } from "astro/zod";
import { allow } from "../lib/limit";
export const server = {
sendMessage: defineAction({
accept: "form",
input: z.object({ email: z.email(), message: z.string().min(1) }),
handler: async (input, context) => {
const limit = await allow(context.clientAddress);
if (!limit.ok) {
throw new ActionError({ code: "TOO_MANY_REQUESTS", message: `Too many messages. Try again in ${limit.retryAfter} seconds.` });
}
await saveMessage(input);
return { sent: true };
},
}),
};Defina o status e o Retry-After na server function, para que quem chama com fetch possa lê-los.
import { createServerFn } from "@tanstack/react-start";
import { getRequestHeader, setResponseHeaders, setResponseStatus } from "@tanstack/react-start/server";
import { allow } from "../lib/limit";
export const sendMessage = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
return { email: String(data.get("email") ?? ""), message: String(data.get("message") ?? "") };
})
.handler(async ({ data }) => {
const limit = await allow(getRequestHeader("x-forwarded-for")?.split(",")[0]?.trim());
if (!limit.ok) {
setResponseStatus(429);
setResponseHeaders(new Headers({ "Retry-After": String(limit.retryAfter) }));
return { ok: false, message: `Too many messages. Try again in ${limit.retryAfter} seconds.` };
}
await saveMessage(data);
return { ok: true, message: "Thanks. We'll reply by email." };
});data() permite que a action retorne um 429 com cabeçalhos junto com a mensagem para o formulário.
import { data } from "react-router";
import { allow } from "../lib/limit";
import type { Route } from "./+types/contact";
export async function action({ request }: Route.ActionArgs) {
const limit = await allow(request.headers.get("x-forwarded-for")?.split(",")[0]?.trim());
if (!limit.ok) {
return data(
{ message: `Too many messages. Try again in ${limit.retryAfter} seconds.` },
{ status: 429, headers: { "Retry-After": String(limit.retryAfter) } },
);
}
await saveMessage(Object.fromEntries(await request.formData()));
return { message: "Thanks. We'll reply by email." };
}getClientAddress() lê o IP pelo seu adapter. fail(429) mantém o formulário na página com a mensagem. Salve o helper como src/lib/limit.ts; o SvelteKit 3 o importa pelo alias #lib em package.json.
import { fail } from "@sveltejs/kit";
import { allow } from "#lib/limit.js";
import type { Actions } from "./$types";
export const actions = {
default: async ({ request, getClientAddress }) => {
const limit = await allow(getClientAddress());
if (!limit.ok) return fail(429, { message: `Too many messages. Try again in ${limit.retryAfter} seconds.` });
await saveMessage(Object.fromEntries(await request.formData()));
return { sent: true };
},
} satisfies Actions;getRequestIP com xForwardedFor lê o IP do cliente atrás de um proxy. Salve o helper em server/utils para importá-lo automaticamente.
export default defineEventHandler(async (event) => {
const limit = await allow(getRequestIP(event, { xForwardedFor: true }));
if (!limit.ok) {
setResponseHeader(event, "Retry-After", limit.retryAfter);
throw createError({ status: 429, message: `Too many messages. Try again in ${limit.retryAfter} seconds.` });
}
await saveMessage(await readBody(event));
return { sent: true };
});Um app de página única não consegue impor um limite, mas deve respeitá-lo. Leia o Retry-After e faça uma contagem regressiva em vez de deixar as pessoas apertarem o botão de novo. Um 429 quota_exceeded não tem Retry-After, então cai na mensagem de erro.
const button = form.querySelector("button");
const status = form.querySelector("[role=status]");
form.addEventListener("submit", async (event) => {
event.preventDefault();
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const retryAfter = response.headers.get("Retry-After");
if (response.status === 429 && retryAfter) {
let wait = Number(retryAfter);
button.disabled = true;
const timer = setInterval(() => {
status.textContent = `Too many messages. Try again in ${wait} seconds.`;
wait -= 1;
if (wait < 0) {
clearInterval(timer);
button.disabled = false;
status.textContent = "";
}
}, 1000);
return;
}
const result = await response.json();
status.textContent = result.ok ? "Thanks. We'll reply by email." : result.error.message;
});O mesmo tratamento num componente Vue: leia o Retry-After, desative o botão e faça a contagem regressiva num ref.
<script setup lang="ts">
import { onBeforeUnmount, ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const wait = ref(0);
const notice = ref("");
let timer: ReturnType<typeof setInterval> | undefined;
function countDown(seconds: number) {
wait.value = seconds;
clearInterval(timer);
timer = setInterval(() => {
wait.value -= 1;
if (wait.value <= 0) clearInterval(timer);
}, 1000);
}
async function send(event: SubmitEvent) {
notice.value = "";
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(event.target as HTMLFormElement),
});
const retryAfter = Number(response.headers.get("Retry-After"));
if (response.status === 429 && retryAfter > 0) {
countDown(retryAfter);
return;
}
const result = await response.json();
notice.value = result.ok ? "Thanks. We'll reply by email." : result.error.message;
}
onBeforeUnmount(() => clearInterval(timer));
</script>
<template>
<form :action="ENDPOINT" method="post" @submit.prevent="send">
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" required />
<p role="status">{{ wait > 0 ? `Too many messages. Try again in ${wait} seconds.` : notice }}</p>
<button :disabled="wait > 0">Send</button>
</form>
</template>No Cloudflare Workers, o binding embutido é a opção mais barata:
[[ratelimits]]
name = "CONTACT_LIMIT"
namespace_id = "1001"
[ratelimits.simple]
limit = 5
period = 60const ip = request.headers.get("CF-Connecting-IP") ?? "no-ip";
const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
if (!success) return new Response("Too many messages", { status: 429, headers: { "Retry-After": "60" } });A Cloudflare documenta esse binding como aproximado e contado separadamente em cada localidade, então um ataque espalhado pode ultrapassá-lo. Para um limite exato entre localidades, a Cloudflare recomenda um Durable Object. Atrás do seu próprio nginx, o limit_req faz a contagem antes de sua aplicação ver o envio:
limit_req_zone $binary_remote_addr zone=contact:10m rate=5r/m;
server {
location = /api/contact {
limit_req zone=contact burst=3 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:3000;
}
}Pelo que contar
- IP e formulário juntos. A escolha usual. Um visitante não consegue inundar este formulário, e usar outro formulário do seu site não gasta a mesma cota.
- Muitas pessoas, um IP. Escritórios, escolas, operadoras de celular e Wi-Fi de conferência colocam muita gente atrás de um endereço. Mantenha o limite por IP folgado o bastante para elas, ou aumente-o em dias de evento.
- O IP real do cliente. Atrás de um proxy ou CDN, leia o cabeçalho que seu host define e confie só nele. Um valor bruto de
X-Forwarded-Forpode ser definido pelo cliente. - O IP do seu próprio servidor. Se seu servidor repassa envios a outro serviço, todo visitante compartilha o IP do seu servidor lá. Limite do seu lado, pelo IP do visitante, antes de repassar.
Os limites de taxa do Ikiform
O Ikiform conta cada envio com exatidão, por IP do visitante e formulário, antes de ler o corpo, com recarga suave em vez de janelas fixas. Cada formulário escolhe um preset em Rate limit. Uma proteção contra enxurradas de 600 por minuto por IP em todos os formulários roda antes de qualquer um deles.
| Preset | Por visitante | Indicado para |
|---|---|---|
| Standard | 5 por minuto, no máximo 3 a cada 10 segundos | A maioria dos formulários |
| Relaxed | 20 por minuto | Eventos em Wi-Fi compartilhado, formulários atrás de login |
| Strict | 2 a cada 10 minutos, mais um a cada 5 minutos | Formulários de contato e de vendas |
| Custom | De 1 a 1.000 por 10 segundos, minuto, 10 minutos, hora ou dia | Uma inscrição por pessoa, quiosques |
O formulário inteiro também aceita 300 por minuto somando todos os visitantes, a menos que você defina um teto Custom. Acima do limite, um envio pelo navegador vê uma página pedindo para ir mais devagar e o fetch recebe 429 rate_limited com Retry-After, que o Ikiform expõe via CORS para o código acima poder lê-lo. A documentação de limites de taxa explica a recarga.
IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.Confira se funciona
- Envie o formulário várias vezes seguidas. Passado o limite, você recebe
429com um cabeçalhoRetry-After, ou a página do Ikiform pedindo para ir mais devagar. - Espere os segundos indicados em
Retry-Aftere envie de novo. O envio é aceito. - Envie de uma segunda rede, como seu celular fora do Wi-Fi. Ela tem sua própria cota.
- Se seu servidor repassa envios, envie rapidamente de dois aparelhos e confira que nenhum compartilha a contagem do outro.
Pegadinhas
- Envios recusados não devem gastar cota. Conte antes do trabalho e não deixe um envio recusado empurrar a janela para a frente, senão um visitante ansioso se tranca do lado de fora.
- Não limite o carregamento da página. Conte envios, não visualizações do formulário, senão uma pessoa recarregando gasta seus envios.
429 rate_limitedUm visitante enviou mais do que o limite de taxa do formulário permite. O Standard permite 5 por minuto por visitante. Espere oRetry-After, ou escolha Relaxed ou Custom para formulários que enviam de um IP compartilhado.- IPv6. Uma pessoa pode ter muitos endereços IPv6. Para um limite rígido, conte pelo prefixo
/64em vez do endereço completo.
Perguntas
Para um formulário de contato, um punhado por visitante é bastante: o preset Strict do Ikiform permite 2 a cada 10 minutos. Formulários de inscrição e de eventos usados em redes compartilhadas precisam de mais, uns 20 por minuto.
Não de forma confiável. Cada instância serverless tem sua própria memória e as instâncias vêm e vão, então a contagem zera aleatoriamente. Use um armazenamento compartilhado como o Redis.
Uma mensagem simples dizendo quanto esperar, tirada do cabeçalho Retry-After, com o botão de envio desativado até lá. Não apague o que a pessoa digitou.
Sim. Todo formulário começa no preset Standard, 5 por minuto por visitante, e você pode mudar para Relaxed, Strict ou Custom por formulário.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.