Hentikan spam formulir kontak

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.

Preet SutharPendiri, Ikiform

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.

Kiriman kontak melewati server route Nuxt dan Nitro storageContactForm.vue$fetchdengantokenclientIp(event)headerdariruntimeConfiguseStoragehitungmenitinisiteverifyactiondanhostIkiformsimpan,skorhoneypot429sisadetikdijendelaPOST /api/contactdi bawah 5manusialewatKiriman kontak melewati server route Nuxt dan Nitro storageContactForm.vue$fetch dengan tokenclientIp(event)header dariruntimeConfiguseStoragehitung menitinisiteverifyaction dan hostIkiformsimpan, skor honeypot429sisa detikdi jendelaPOST /api/contactdi bawah 5manusialewat
  1. Komponen mengirim field dan token Turnstile ke /api/contact dengan $fetch.
  2. Server route membaca IP client dari header yang disebut di runtimeConfig.
  3. Route menghitung kiriman dari IP itu di menit berjalan dalam Nitro storage. Jika lebih dari lima, route melempar 429 dengan sisa detiknya.
  4. Di bawah limit, route memverifikasi token dengan siteverify, memeriksa action dan host-nya.
  5. Lalu route meneruskan field ke Ikiform, yang menyimpannya dan memberi skor pada honeypot.
Saat development, mount storage ada di memori. Di production kode yang sama berbicara dengan Redis, karena nuxt.config me-mount driver yang berbeda.

Setup proyek

File
app/
  components/
    ContactForm.vue
  composables/
    useTurnstile.ts
server/
  api/
    contact.post.ts
  utils/
    spam.ts
nuxt.config.ts
.env
.env
NUXT_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-for
nuxt.config.ts
const 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 runtimeConfig bisa ditimpa saat runtime oleh variabel NUXT_: turnstileSecretKey oleh NUXT_TURNSTILE_SECRET_KEY, public.turnstileSiteKey oleh NUXT_PUBLIC_TURNSTILE_SITE_KEY. Key di luar public tidak pernah sampai ke browser.
  • routeRules mengirim CSP hanya di /contact. Nuxt menyisipkan skrip payload dan hydration secara inline, jadi policy ini mempertahankan 'unsafe-inline'; modul komunitas nuxt-security bisa membuat nonce jika Anda butuh policy yang lebih ketat.
  • Driver upstash hanya di-mount di production dan membaca UPSTASH_REDIS_REST_URL serta UPSTASH_REDIS_REST_TOKEN dari environment. Driver ini butuh paket @upstash/redis, jadi instal dengan npm install @upstash/redis.

Utilitas server

File di server/utils diimpor otomatis ke setiap server route:

server/utils/spam.ts
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;
  }
}
  • clientIp membaca tepat satu header, yaitu header yang ditimpa hosting Anda. Atur NUXT_CLIENT_IP_HEADER ke x-forwarded-for di Vercel, x-nf-client-connection-ip di Netlify, dan cf-connecting-ip di 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.
  • $fetch mengirim body siteverify sebagai JSON, yang diterima siteverify. Pemeriksaan host memakai getRequestHost dan dilewati saat development, karena token uji membawa hostname tiruan.

Server route

server/api/contact.post.ts
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.ts membuat Nitro hanya menjawab POST. Selain itu mendapat 405.
  • Limit berjalan sebelum readBody, jadi banjir request tidak pernah memakan parsing body atau panggilan siteverify.
  • Setiap kegagalan melempar createError. Body JSON membawa message, yang ditampilkan komponen.
  • Origin pengunjung diteruskan, karena fetch sisi server tidak mengirimnya dan Ikiform membacanya untuk mengenali uji gratis dari localhost.
  • Honeypot _gotcha ikut diteruskan tanpa diubah, sehingga Ikiform menyimpan honeypot yang terisi sebagai spam alih-alih membuangnya.
Versi sisi server mengirim dengan secret key, sehingga Ikiform melewati Allowed domains dan pemeriksaan manusianya. Semua kiriman lalu datang dari IP server Anda, dan batas Standard 5 per menit per pengunjung menghitung semua pengunjung Anda sebagai satu. Pilih rate limit Relaxed atau Custom untuk formulir yang Anda kirim lewat server, dan simpan IKIFORM_SECRET_KEY di environment variable khusus server.

Composable dan formulir

app/composables/useTurnstile.ts
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 };
}
app/components/ContactForm.vue
<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 menyentuh window.
  • 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 .hp menyembunyikan 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:

Terminal
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"}'
done

Lima 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

HostingNUXT_CLIENT_IP_HEADERDriver storage
Vercelx-forwarded-forupstash, dari Vercel Marketplace
Netlifyx-nf-client-connection-ipupstash atau redis
Cloudflarecf-connecting-ipupstash; Workers KV bersifat eventually consistent, jadi hitungan tertinggal
Server NodeHeader yang diatur proxy Andaredis, 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

  1. Jalankan nuxt dev dengan test key dan kirim pesan. Pesan muncul di Submissions. Workspace tanpa paket menerima 50 uji dari localhost per bulan.
  2. Kirim enam pesan dalam satu menit. Pesan keenam menampilkan sisa detiknya.
  3. Atur NUXT_TURNSTILE_SECRET_KEY ke secret yang selalu gagal lalu restart. Setiap kiriman menampilkan error pemeriksaan manusia.
  4. Request /contact dengan curl -I setelah nuxt build. Responsnya membawa Content-Security-Policy Anda.
  5. 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

MasalahYang 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 instanceMount storage masih di memori. Pastikan $production.nitro.storage me-mount driver bersama dan kredensialnya sudah diatur.
Skrip lolos dari limit dengan mengubah headerAnda membaca getRequestIP dengan xForwardedFor: true di hosting yang tidak menimpa header itu. Baca header milik hosting lewat clientIp.
window is not defined saat server renderingKode Turnstile berjalan di luar onMounted. Simpan setiap akses window di dalam lifecycle hook composable.

Pertanyaan

Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.