Hentikan spam formulir kontak7 menit baca
Perlindungan spam formulir Nuxt dengan Turnstile
Lindungi formulir Nuxt dari spam: server route Nitro dengan Turnstile, limit per IP di Nitro storage, secret di runtimeConfig, dan CSP yang diatur di routeRules.
Kirim formulir dengan $fetch ke server route yang menghitung percobaan per IP client di Nitro storage, memverifikasi token Turnstile dengan siteverify, dan meneruskan field-nya bersama honeypot. Simpan secret di runtimeConfig privat, yang diisi dari environment variable NUXT_ saat runtime. Kirim CSP untuk halaman kontak dari routeRules, dengan mengizinkan challenges.cloudflare.com untuk skrip dan frame-nya.
Apa yang berbeda di Nuxt
Server Nuxt adalah Nitro, dan Nitro membawa dua hal yang dibutuhkan pemeriksaan spam. Server route di server/api adalah endpoint biasa yang sudah terhubung dengan $fetch, dan useStorage() memberi Anda key-value store yang backend-nya Anda pilih per environment: memori saat development, Redis atau driver lain di production. Itu membuat rate limit cukup diubah lewat konfigurasi, bukan dependensi.
Dua kebiasaan Nuxt menimbulkan masalah di sini. getRequestIP(event, { xForwardedFor: true }) mengembalikan entri X-Forwarded-For pertama, yang oleh dokumentasi h3 sendiri disebut input dari client. Dan file .env tidak dibaca di production, jadi secret harus diatur sebagai environment variable platform.
- Komponen mengirim field dan token Turnstile ke /api/contact dengan $fetch.
- Server route membaca IP client dari header yang disebut di runtimeConfig.
- Route menghitung kiriman dari IP itu di menit berjalan dalam Nitro storage. Jika lebih dari lima, route melempar 429 dengan sisa detiknya.
- Di bawah limit, route memverifikasi token dengan siteverify, memeriksa action dan host-nya.
- Lalu route meneruskan field ke Ikiform, yang menyimpannya dan memberi skor pada honeypot.
Setup proyek
app/
components/
ContactForm.vue
composables/
useTurnstile.ts
server/
api/
contact.post.ts
utils/
spam.ts
nuxt.config.ts
.envNUXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
NUXT_TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_CLIENT_IP_HEADER=x-forwarded-forconst policy = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com",
"frame-src https://challenges.cloudflare.com",
"object-src 'none'",
"base-uri 'self'",
].join("; ");
export default defineNuxtConfig({
runtimeConfig: {
turnstileSecretKey: "",
ikiformSecretKey: "",
clientIpHeader: "x-forwarded-for",
public: {
turnstileSiteKey: "",
},
},
routeRules: {
"/contact": { headers: { "Content-Security-Policy": policy } },
},
$production: {
nitro: {
storage: {
ratelimit: { driver: "upstash" },
},
},
},
});- Setiap key
runtimeConfigbisa ditimpa saat runtime oleh variabelNUXT_:turnstileSecretKeyolehNUXT_TURNSTILE_SECRET_KEY,public.turnstileSiteKeyolehNUXT_PUBLIC_TURNSTILE_SITE_KEY. Key di luarpublictidak pernah sampai ke browser. routeRulesmengirim CSP hanya di/contact. Nuxt menyisipkan skrip payload dan hydration secara inline, jadi policy ini mempertahankan'unsafe-inline'; modul komunitasnuxt-securitybisa membuat nonce jika Anda butuh policy yang lebih ketat.- Driver
upstashhanya di-mount di production dan membacaUPSTASH_REDIS_REST_URLsertaUPSTASH_REDIS_REST_TOKENdari environment. Driver ini butuh paket@upstash/redis, jadi instal dengannpm install @upstash/redis.
Utilitas server
File di server/utils diimpor otomatis ke setiap server route:
import type { H3Event } from "h3";
const WINDOW_SECONDS = 60;
const LIMIT = 5;
export function clientIp(event: H3Event) {
const header = useRuntimeConfig(event).clientIpHeader;
return getRequestHeader(event, header)?.split(",")[0]?.trim() || "unknown";
}
export async function secondsToWait(ip: string) {
const storage = useStorage("ratelimit");
const window = Math.floor(Date.now() / 1000 / WINDOW_SECONDS);
const key = `contact:${ip}:${window}`;
const count = ((await storage.getItem<number>(key)) ?? 0) + 1;
await storage.setItem(key, count);
if (count <= LIMIT) return 0;
return (window + 1) * WINDOW_SECONDS - Math.floor(Date.now() / 1000);
}
export async function isHuman(event: H3Event, token: string, ip: string) {
if (!token || token.length > 2048) return false;
try {
const verdict = await $fetch<{ success: boolean; hostname?: string; action?: string }>("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
body: { secret: useRuntimeConfig(event).turnstileSecretKey, response: token, remoteip: ip },
});
return verdict.success && verdict.action === "contact" && (import.meta.dev || verdict.hostname === getRequestHost(event));
} catch {
return false;
}
}clientIpmembaca tepat satu header, yaitu header yang ditimpa hosting Anda. AturNUXT_CLIENT_IP_HEADERkex-forwarded-fordi Vercel,x-nf-client-connection-ipdi Netlify, dancf-connecting-ipdi Cloudflare.- Penghitungnya berupa fixed window di Nitro storage. Baca-lalu-tulis tidak atomik, jadi dua kiriman di saat yang sama bisa sama-sama lolos di bawah limit; untuk formulir kontak itu bisa diterima. Key tidak kedaluwarsa dengan sendirinya, jadi beri database Redis kebijakan eviction.
$fetchmengirim body siteverify sebagai JSON, yang diterima siteverify. Pemeriksaan host memakaigetRequestHostdan dilewati saat development, karena token uji membawa hostname tiruan.
Server route
interface ContactBody {
name?: string;
email?: string;
company?: string;
message?: string;
_gotcha?: string;
"cf-turnstile-response"?: string;
}
export default defineEventHandler(async (event) => {
const ip = clientIp(event);
const wait = await secondsToWait(ip);
if (wait) throw createError({ status: 429, message: `Too many messages. Try again in ${wait} seconds.` });
const body = await readBody<ContactBody>(event);
const fields = {
name: body.name?.trim().slice(0, 120) ?? "",
email: body.email?.trim() ?? "",
company: body.company?.trim().slice(0, 120) ?? "",
message: body.message?.trim().slice(0, 5000) ?? "",
_gotcha: body._gotcha ?? "",
};
if (!fields.email.includes("@")) throw createError({ status: 400, message: "Enter an address we can reply to." });
if (!(await isHuman(event, body["cf-turnstile-response"] ?? "", ip))) {
throw createError({ status: 403, message: "We couldn't confirm you're human. Try again." });
}
const origin = getRequestHeader(event, "origin");
await $fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
Authorization: `Bearer ${useRuntimeConfig(event).ikiformSecretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: fields,
}).catch((failure) => {
throw createError({
status: failure.status ?? 502,
message: failure.data?.error?.message ?? "Your message didn't send. Try again.",
});
});
return { sent: true };
});- Akhiran
.post.tsmembuat Nitro hanya menjawabPOST. Selain itu mendapat405. - Limit berjalan sebelum
readBody, jadi banjir request tidak pernah memakan parsing body atau panggilan siteverify. - Setiap kegagalan melempar
createError. Body JSON membawamessage, yang ditampilkan komponen. Originpengunjung diteruskan, karena fetch sisi server tidak mengirimnya dan Ikiform membacanya untuk mengenali uji gratis darilocalhost.- Honeypot
_gotchaikut diteruskan tanpa diubah, sehingga Ikiform menyimpan honeypot yang terisi sebagai spam alih-alih membuangnya.
IKIFORM_SECRET_KEY di environment variable khusus server.Composable dan formulir
interface TurnstileApi {
render(
container: HTMLElement,
options: { sitekey: string; action: string; callback: (token: string) => void; "expired-callback": () => void },
): string;
reset(widgetId: string): void;
remove(widgetId: string): void;
}
let loading: Promise<TurnstileApi> | undefined;
function load() {
loading ??= new Promise<TurnstileApi>((resolve, reject) => {
const script = document.createElement("script");
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
script.async = true;
script.onload = () => {
const api = (window as Window & { turnstile?: TurnstileApi }).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 useTurnstile(action: string) {
const { turnstileSiteKey } = useRuntimeConfig().public;
const box = ref<HTMLElement>();
const token = ref("");
let api: TurnstileApi | undefined;
let widgetId: string | undefined;
onMounted(async () => {
api = await load();
if (!box.value) return;
widgetId = api.render(box.value, {
sitekey: turnstileSiteKey,
action,
callback: (value) => (token.value = value),
"expired-callback": () => (token.value = ""),
});
});
onBeforeUnmount(() => {
if (widgetId) api?.remove(widgetId);
});
function reset() {
token.value = "";
if (widgetId) api?.reset(widgetId);
}
return { box, token, reset };
}<script setup lang="ts">
const fields = reactive({ name: "", email: "", company: "", message: "", _gotcha: "" });
const { box, token, reset } = useTurnstile("contact");
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");
async function send() {
status.value = "sending";
error.value = "";
try {
await $fetch("/api/contact", { method: "POST", body: { ...fields, "cf-turnstile-response": token.value } });
status.value = "sent";
} catch (failure) {
error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't send your message. Try again.";
status.value = "idle";
reset();
}
}
</script>
<template>
<p v-if="status === 'sent'" role="status">Thanks. We'll reply by email.</p>
<form v-else @submit.prevent="send">
<label for="name">Name</label>
<input id="name" v-model="fields.name" autocomplete="name" required maxlength="120" />
<label for="email">Email</label>
<input id="email" v-model="fields.email" type="email" autocomplete="email" required />
<label for="company">Company <span>(optional)</span></label>
<input id="company" v-model="fields.company" autocomplete="organization" maxlength="120" />
<label for="message">Message</label>
<textarea id="message" v-model="fields.message" rows="5" required minlength="10" />
<div class="hp" aria-hidden="true">
<label for="hp-field">Leave this empty</label>
<input id="hp-field" v-model="fields._gotcha" type="text" tabindex="-1" autocomplete="off" />
</div>
<div ref="box" />
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="status === 'sending' || !token">{{ status === "sending" ? "Sending" : "Send message" }}</button>
</form>
</template>- Composable hanya berjalan di
onMounted, jadi render server tidak pernah menyentuhwindow. - Tombol menunggu token, dan
reset()meminta token baru setelah setiap kegagalan, karena token hanya berlaku sekali dan hidup selama 300 detik. - Formulir ini butuh JavaScript. Untuk versi yang bisa mengirim tanpanya, lihat bagian berikutnya.
- Class
.hpmenyembunyikan honeypot di luar layar, seperti di panduan honeypot.
Uji secara lokal
Saat development, mount ratelimit ada di memori, jadi hitungan ter-reset saat nuxt dev di-restart. Anda bisa melihat limit bekerja tanpa browser. Kiriman tanpa token gagal di siteverify dengan 403, dan masing-masing tetap dihitung:
for i in 1 2 3 4 5 6; do
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/api/contact \
-H "Content-Type: application/json" -d '{"email":"test@example.com"}'
doneLima baris pertama mencetak 403 dan baris keenam mencetak 429. Secara lokal tidak ada proxy yang mengatur header IP, jadi setiap request dihitung sebagai unknown; itu memang diharapkan, dan itulah sebabnya header ditaruh di konfigurasi, bukan di kode.
Deploy
| Hosting | NUXT_CLIENT_IP_HEADER | Driver storage |
|---|---|---|
| Vercel | x-forwarded-for | upstash, dari Vercel Marketplace |
| Netlify | x-nf-client-connection-ip | upstash atau redis |
| Cloudflare | cf-connecting-ip | upstash; Workers KV bersifat eventually consistent, jadi hitungan tertinggal |
| Server Node | Header yang diatur proxy Anda | redis, atau memori pada satu instance |
Atur setiap variabel NUXT_ di dashboard hosting. Nuxt membacanya saat server dimulai, jadi mengganti key butuh restart, bukan rebuild. Tambahkan setiap hostname production dan preview ke widget Turnstile.
Biarkan Ikiform menjalankan pemeriksaannya
Tanpa server route, halaman kontak bisa di-prerender bersama sisa situs nuxt generate. Arahkan formulir biasa ke endpoint Ikiform Anda dengan method="post" dan action berisi URL endpoint Anda, lalu hapus server/api/contact.post.ts dan composable-nya.
Atur Spam check formulir ke Ikiform. Kiriman tanpa token mendapat halaman pemeriksaan singkat di ikiform.com yang menjalankan Turnstile dan meneruskan field-nya, jadi CSP Anda tidak butuh entri Cloudflare. Pilih Rate limit Strict, 2 per 10 menit per pengunjung, dan daftarkan domain Anda di Allowed domains. Ikiform menyimpan spam yang tertangkap selama 30 hari beserta alasannya. Panduan setup Nuxt membahas endpoint dan key-nya.
Pastikan berfungsi
- Jalankan
nuxt devdengan test key dan kirim pesan. Pesan muncul di Submissions. Workspace tanpa paket menerima 50 uji darilocalhostper bulan. - Kirim enam pesan dalam satu menit. Pesan keenam menampilkan sisa detiknya.
- Atur
NUXT_TURNSTILE_SECRET_KEYke secret yang selalu gagal lalu restart. Setiap kiriman menampilkan error pemeriksaan manusia. - Request
/contactdengancurl -Isetelahnuxt build. Responsnya membawaContent-Security-PolicyAnda. - Kirim dengan honeypot terisi,
_gotcha=bot. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook.
Hal yang sering menjebak
| Masalah | Yang harus dilakukan |
|---|---|
| Secret kosong di production | .env tidak dibaca saat runtime di production. Atur NUXT_TURNSTILE_SECRET_KEY dan yang lainnya di environment hosting. |
| Limit ter-reset di setiap deploy atau instance | Mount storage masih di memori. Pastikan $production.nitro.storage me-mount driver bersama dan kredensialnya sudah diatur. |
| Skrip lolos dari limit dengan mengubah header | Anda membaca getRequestIP dengan xForwardedFor: true di hosting yang tidak menimpa header itu. Baca header milik hosting lewat clientIp. |
window is not defined saat server rendering | Kode Turnstile berjalan di luar onMounted. Simpan setiap akses window di dalam lifecycle hook composable. |
Pertanyaan
Render widget secara eksplisit di onMounted, simpan token-nya di ref, lalu kirim bersama field ke server route. Route mengirimnya bersama secret key dari runtimeConfig ke siteverify dan hanya melanjutkan jika berhasil.
Hitung request per IP client dengan useStorage milik Nitro, yang di-mount ke Redis atau Upstash di production agar setiap instance berbagi hitungan. Lempar 429 dengan createError saat hitungan melewati limit Anda.
Tidak dengan xForwardedFor true di hosting yang meneruskan X-Forwarded-For milik client apa adanya, karena entri pertamanya adalah apa pun yang dikirim client. Baca header yang ditimpa hosting Anda sebagai gantinya.
Tidak, tidak ada paket gratis. Pengujian gratis: Anda bisa mengirim 50 kiriman uji per bulan dari localhost sebelum membayar. Untuk go live, Pro mulai $6/bln untuk 500 kiriman, atau Anda bisa bayar sekali untuk 10.000 kiriman seharga $49 atau 50.000 kiriman seharga $149, dengan kredit yang tidak pernah kedaluwarsa.
Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.