Hentikan spam formulir kontak5 menit baca
Cara membatasi rate kiriman formulir
Batasi rate kiriman formulir per pengunjung dengan store bersama, jawab 429 dengan Retry-After, dan hindari mengunci kantor yang berbagi satu IP.
Hitung kiriman per IP pengunjung di store yang dipakai bersama oleh setiap instance server, seperti Redis, izinkan lonjakan kecil, dan jawab 429 dengan header Retry-After saat hitungannya habis. Penghitung in-memory hanya berfungsi di satu server yang berjalan lama, tidak pernah di hosting serverless. Tentukan batasnya dari cara orang sungguhan memakai formulir, dan beri ruang untuk kantor dan acara di mana banyak orang berbagi satu IP.
Apa yang berbeda dari rate limit
Rate limit tidak membedakan spam dari pesan asli. Ia menghentikan volume: skrip yang mengirim berulang-ulang, bot yang terus memutari formulir Anda, atau orang yang menekan tombol bertubi-tubi. Itu menjadikannya lapisan termurah, karena berjalan sebelum body dibaca, dan yang paling kasar, karena tidak bisa membedakan penyerang dari kantor yang sibuk. Tiga hal menentukan apakah ia membantu atau merugikan: di mana hitungan disimpan, berdasarkan apa Anda menghitung, dan seberapa cepat jatahnya kembali.
- Seorang pengunjung mengirim 3 kiriman dalam 10 detik. Semuanya diterima.
- Kiriman berikutnya ditolak dengan 429 dan header Retry-After yang menyebutkan berapa lama harus menunggu.
- Setelah itu, satu kiriman lagi diizinkan setiap 12 detik.
- Setelah satu menit penuh tanpa kiriman, seluruh jatah kembali.
Di mana hitungan disimpan
| Store | Berfungsi di | Waspadai |
|---|---|---|
Map di memori | Satu server yang berjalan lama | Hilang saat restart. Setiap instance serverless punya map sendiri, jadi batas sebenarnya tidak diketahui |
| Redis, seperti Upstash | Apa saja, termasuk serverless dan edge | Satu panggilan jaringan per kiriman. Pilih region yang dekat dengan server Anda |
| Binding rate limiting Cloudflare Workers | Workers | Perkiraan dan dihitung per lokasi Cloudflare, hanya dengan periode 10 atau 60 detik |
Reverse proxy, seperti limit_req nginx | Server di balik proxy Anda sendiri | Dihitung per proxy. Butuh IP client yang asli |
Algoritmanya kurang penting dibanding store-nya, tetapi membentuk apa yang dirasakan pengunjung. Fixed window di-reset setiap menit, jadi skrip bisa mengirim satu jatah penuh di akhir satu menit dan satu lagi di awal menit berikutnya. Sliding window memperhalus tepi itu dengan menimbang jendela sebelumnya. Token bucket, atau algoritma serupa yang dipakai Ikiform, mengisi ulang satu kiriman demi satu, sehingga orang yang salah ketik bisa mengirim lagi beberapa detik kemudian tanpa membuka pintu bagi lonjakan. Untuk formulir kontak, semuanya bisa dipakai asalkan angkanya sesuai dengan cara orang memakainya.
Contoh di bawah memakai @upstash/ratelimit dari Upstash, karena bekerja sama persis di setiap framework dan di hosting serverless. Atur UPSTASH_REDIS_REST_URL dan UPSTASH_REDIS_REST_TOKEN, lalu simpan helper-nya sekali:
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 adalah waktu Unix dalam milidetik, jadi helper mengubahnya menjadi detik bulat untuk Retry-After. Kiriman tanpa IP berbagi satu bucket, sehingga header yang hilang tidak menjadi tiket gratis.
Terapkan di framework Anda
Formulir HTML biasa tidak punya server untuk menghitung apa pun. Kirim ke Ikiform dan pilih preset di formulir; pengunjung yang melewati batas melihat halaman perlambat singkat, bukan error.
<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>Periksa batas di bagian paling atas server action, sebelum pekerjaan lain.
"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 memberi IP pengunjung di halaman on-demand. TOO_MANY_REQUESTS dipetakan ke 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 };
},
}),
};Atur status dan Retry-After dari server function, agar pemanggil fetch bisa membacanya.
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() memungkinkan action mengembalikan 429 dengan header bersama pesan untuk formulir.
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() membaca IP dari adapter Anda. fail(429) mempertahankan formulir di halaman beserta pesannya. Simpan helper sebagai src/lib/limit.ts; SvelteKit 3 mengimpornya lewat alias #lib di 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 dengan xForwardedFor membaca IP client di balik proxy. Simpan helper di server/utils agar diimpor otomatis.
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 };
});Single-page app tidak bisa menerapkan batas, tetapi harus menghormatinya. Baca Retry-After dan hitung mundur alih-alih membiarkan orang menekan tombol lagi. 429 quota_exceeded tidak punya Retry-After, jadi jatuh ke pesan error.
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;
});Penanganan yang sama di komponen Vue: baca Retry-After, nonaktifkan tombol, dan hitung mundur di 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>Di Cloudflare Workers, binding bawaan adalah opsi termurah:
[[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" } });Cloudflare mendokumentasikan binding itu sebagai perkiraan dan dihitung terpisah di setiap lokasi, jadi serangan yang tersebar bisa melampauinya. Untuk batas yang tepat di semua lokasi, Cloudflare merekomendasikan Durable Object. Di balik nginx Anda sendiri, limit_req melakukan penghitungan sebelum aplikasi Anda melihat kiriman:
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;
}
}Berdasarkan apa menghitung
- IP dan formulir bersamaan. Pilihan yang umum. Satu pengunjung tidak bisa membanjiri formulir ini, dan memakai formulir lain di situs Anda tidak menghabiskan jatah yang sama.
- Banyak orang, satu IP. Kantor, sekolah, operator seluler, dan Wi-Fi konferensi menempatkan banyak orang di balik satu alamat. Buat batas per IP cukup longgar untuk mereka, atau naikkan pada hari acara.
- IP client yang asli. Di balik proxy atau CDN, baca header yang diatur hosting Anda dan hanya percayai yang itu. Nilai
X-Forwarded-Formentah bisa diatur oleh client. - IP server Anda sendiri. Jika server Anda meneruskan kiriman ke layanan lain, setiap pengunjung berbagi IP server Anda di sana. Batasi di sisi Anda, berdasarkan IP pengunjung, sebelum meneruskan.
Rate limit Ikiform
Ikiform menghitung setiap kiriman secara tepat, per IP pengunjung dan formulir, sebelum membaca body, dengan pengisian ulang yang mulus alih-alih jendela tetap. Setiap formulir memilih preset di Rate limit. Proteksi flood 600 per menit per IP di semua formulir berjalan sebelum semuanya.
| Preset | Per pengunjung | Cocok untuk |
|---|---|---|
| Standard | 5 per menit, maksimal 3 per 10 detik | Sebagian besar formulir |
| Relaxed | 20 per menit | Acara dengan Wi-Fi bersama, formulir di balik login |
| Strict | 2 per 10 menit, satu lagi setiap 5 menit | Formulir kontak dan penjualan |
| Custom | 1 hingga 1.000 per 10 detik, menit, 10 menit, jam, atau hari | Satu entri per orang, kios |
Seluruh formulir juga menerima 300 per menit dari semua pengunjung kecuali Anda mengatur batas Custom. Melewati batas, kiriman browser melihat halaman perlambat dan fetch mendapat 429 rate_limited dengan Retry-After, yang diekspos Ikiform lewat CORS agar kode di atas bisa membacanya. Dokumentasi rate limit menjelaskan pengisian ulangnya.
IKIFORM_SECRET_KEY di environment variable khusus server.Pastikan berfungsi
- Kirim formulir beberapa kali berturut-turut. Setelah melewati batas Anda mendapat
429dengan headerRetry-After, atau halaman perlambat dari Ikiform. - Tunggu selama jumlah detik di
Retry-Afterlalu kirim lagi. Kiriman diterima. - Kirim dari jaringan kedua, seperti ponsel Anda tanpa Wi-Fi. Jaringan itu punya jatahnya sendiri.
- Jika server Anda meneruskan kiriman, kirim dari dua perangkat dengan cepat dan pastikan keduanya tidak berbagi hitungan.
Hal yang sering menjebak
- Kiriman yang ditolak tidak boleh memakan jatah. Hitung sebelum pekerjaan, dan jangan biarkan kiriman yang ditolak memperpanjang jendela, atau pengunjung yang gugup akan mengunci dirinya sendiri.
- Jangan batasi pemuatan halaman. Hitung kiriman, bukan tampilan formulir, atau satu orang yang memuat ulang akan menghabiskan jatah kirimnya.
429 rate_limitedSatu pengunjung mengirim melebihi rate limit formulir. Standard mengizinkan 5 per menit per pengunjung. Tunggu sesuaiRetry-After, atau pilih Relaxed atau Custom untuk formulir yang mengirim dari satu IP bersama.- IPv6. Satu orang bisa memegang banyak alamat IPv6. Untuk batas yang ketat, hitung berdasarkan prefix
/64, bukan alamat lengkap.
Pertanyaan
Untuk formulir kontak, beberapa per pengunjung sudah lebih dari cukup: preset Strict milik Ikiform mengizinkan 2 per 10 menit. Formulir pendaftaran dan acara yang dipakai dari jaringan bersama butuh lebih, sekitar 20 per menit.
Tidak dengan andal. Setiap instance serverless punya memorinya sendiri dan instance datang dan pergi, jadi hitungan di-reset secara acak. Pakai store bersama seperti Redis.
Pesan sederhana yang menyebutkan berapa lama harus menunggu, diambil dari header Retry-After, dengan tombol kirim dinonaktifkan sampai saat itu. Jangan hapus apa yang sudah diketik orang itu.
Ya. Setiap formulir dimulai dengan preset Standard, 5 per menit per pengunjung, dan Anda bisa beralih ke Relaxed, Strict, atau Custom per formulir.
Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.