Hentikan spam formulir kontak

Hentikan spam formulir kontak7 menit baca

Perlindungan spam formulir SvelteKit dengan Turnstile

Lindungi formulir SvelteKit dari spam: form action dengan getClientAddress, limit per IP, Turnstile di balik use:enhance, dan CSP yang mengizinkannya.

Preet SutharPendiri, Ikiform

Lakukan pemeriksaan di form action: batasi berdasarkan getClientAddress(), verifikasi token Turnstile dengan siteverify, lalu teruskan field-nya bersama honeypot. Render widget dengan Svelte action dan reset di callback use:enhance, karena setiap token hanya berlaku sekali. Izinkan challenges.cloudflare.com di opsi csp plugin sveltekit(), dan di adapter-node beri tahu SvelteKit header proxy mana yang berisi IP asli.

Apa yang berbeda di SvelteKit

SvelteKit memberi Anda IP pengunjung secara langsung: getClientAddress() di request event. Asal nilai itu bergantung pada adapter, dan di adapter-node nilai default-nya salah jika ada proxy di depannya. Begitu bagian ini benar, pemeriksaan lainnya pas dengan rapi di dalam form action.

Dari mana getClientAddress() mendapatkan IP pengunjung di setiap adapterform actionmemanggilgetClientAddress()adapter Andaadapter-nodealamat socket, atauADDRESS_HEADERadapter-verceldari edge Verceladapter-netlifydari edge Netlifyadapter-cloudflaredari cf-connecting-ipDari mana getClientAddress() mendapatkan IP pengunjung di setiap adapterform actionmemanggilgetClientAddress()adapterAndaadapter-nodealamat socket, atauADDRESS_HEADERadapter-verceldari edge Verceladapter-netlifydari edge Netlifyadapter-cloudflaredaricf-connecting-ip
  1. Form action meminta alamat client dari request event.
  2. SvelteKit menyerahkan jawabannya ke adapter yang Anda pakai untuk deploy.
  3. adapter-node memakai remote address dari socket kecuali Anda mengatur ADDRESS_HEADER dan XFF_DEPTH.
  4. Adapter Vercel, Netlify, dan Cloudflare membaca alamat yang disediakan platformnya, jadi tidak ada yang perlu dikonfigurasi.
Di belakang nginx atau load balancer, adapter-node melihat alamat proxy untuk setiap pengunjung sampai Anda mengatur ADDRESS_HEADER, dan satu alamat bersama mengubah limit per IP menjadi limit untuk seluruh situs.

Detail SvelteKit lainnya adalah use:enhance. Fitur ini mengirim tanpa memuat ulang halaman, yang lebih nyaman bagi orang tetapi berarti Turnstile tidak pernah merender ulang dengan sendirinya. Widget harus di-reset secara manual setelah setiap jawaban.

Setup proyek

File
src/
  env.ts
  lib/
    turnstile.ts
    server/
      spam.ts
  routes/
    contact/
      +page.server.ts
      +page.svelte
vite.config.ts
.env
.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
src/env.ts
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  PUBLIC_TURNSTILE_SITE_KEY: { public: true },
  TURNSTILE_SECRET_KEY: {},
  IKIFORM_SECRET_KEY: {},
  UPSTASH_REDIS_REST_URL: {},
  UPSTASH_REDIS_REST_TOKEN: {},
});
  • SvelteKit 3 membaca variabel yang Anda deklarasikan di src/env.ts. Hanya site key yang punya public: true, jadi hanya itu yang bisa diberikan $app/env/public ke browser. Apa pun di direktori server, seperti src/lib/server, sama sekali tidak bisa diimpor oleh kode client; SvelteKit menggagalkan build jika Anda mencobanya.
  • Variabel yang dideklarasikan dibaca saat aplikasi dimulai kecuali Anda mengatur static: true, jadi key yang dirotasi butuh restart, bukan rebuild. Modul $env/* masih berfungsi tetapi sudah deprecated di SvelteKit 3.
  • Test key Cloudflare lolos di host mana pun, termasuk localhost.
vite.config.ts
import adapter from "@sveltejs/adapter-node";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    sveltekit({
      adapter: adapter(),
      csp: {
        mode: "auto",
        directives: {
          "script-src": ["self", "https://challenges.cloudflare.com"],
          "frame-src": ["https://challenges.cloudflare.com"],
          "object-src": ["none"],
          "base-uri": ["self"],
        },
      },
    }),
  ],
});

SvelteKit 3 menerima opsinya di vite.config.ts, yang diberikan ke plugin sveltekit(); svelte.config.js tidak lagi dibaca. Dengan mode: "auto", SvelteKit menambahkan nonce ke skrip inline miliknya di halaman yang dirender server dan hash di halaman yang di-prerender, jadi policy tidak butuh 'unsafe-inline'. Turnstile dimuat dari host-nya, yang diizinkan script-src, dan menggambar iframe yang diizinkan frame-src.

Helper khusus server

src/lib/server/spam.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import { dev } from "$app/env";
import { TURNSTILE_SECRET_KEY, UPSTASH_REDIS_REST_TOKEN, UPSTASH_REDIS_REST_URL } from "$app/env/private";

const limiter = new Ratelimit({
  redis: new Redis({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN }),
  limiter: Ratelimit.slidingWindow(5, "60 s"),
  prefix: "contact",
});

export async function secondsToWait(ip: string) {
  const { success, reset } = await limiter.limit(ip);
  return success ? 0 : Math.max(1, Math.ceil((reset - Date.now()) / 1000));
}

export async function isHuman(token: string, ip: string, host: string) {
  if (!token || token.length > 2048) return false;
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ secret: TURNSTILE_SECRET_KEY, response: token, remoteip: ip }),
    });
    const verdict = (await response.json()) as { success: boolean; hostname?: string; action?: string };
    return verdict.success && verdict.action === "contact" && (dev || verdict.hostname === host);
  } catch {
    return false;
  }
}
  • Limit-nya berupa sliding window di Redis, dipakai bersama oleh setiap instance server, lima pesan per menit per IP. reset kembali dalam milidetik dan diubah menjadi detik penuh untuk menunggu.
  • dev dari $app/env melewati pemeriksaan hostname secara lokal, karena token uji membawa hostname tiruan.
  • Siteverify menjawab timeout-or-duplicate untuk token yang lebih tua dari 300 detik atau sudah terpakai, itulah sebabnya halaman me-reset widget.

Form action

src/routes/contact/+page.server.ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import { isHuman, secondsToWait } from "#lib/server/spam.js";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request, getClientAddress, url }) => {
    const ip = getClientAddress();
    const wait = await secondsToWait(ip);
    if (wait) return fail(429, { wait, error: `Too many messages. Try again in ${wait} seconds.` });

    const form = await request.formData();
    const values = {
      name: String(form.get("name") ?? "").trim(),
      email: String(form.get("email") ?? "").trim(),
      message: String(form.get("message") ?? "").trim(),
    };
    if (values.message.length < 10) return fail(400, { values, error: "A few words more, please." });
    if (!(await isHuman(String(form.get("cf-turnstile-response") ?? ""), ip, url.hostname))) {
      return fail(403, { values, error: "We couldn't confirm you're human. Try again." });
    }

    form.delete("cf-turnstile-response");
    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("origin") ?? url.origin,
      },
      body: form,
    }).catch(() => null);
    if (!response?.ok) return fail(502, { values, error: "Your message didn't send. Try again." });
    return { sent: true };
  },
} satisfies Actions;
  • Limit berjalan sebelum body di-parsing. fail(429) membuat pengunjung tetap di halaman dengan pesannya.
  • Setiap kegagalan lain mengembalikan values, sehingga input terisi kembali setelah error.
  • FormData asli diteruskan, termasuk honeypot, tanpa token. Ikiform memberi skor pada honeypot yang terisi dan menyimpan pesannya sebagai spam.
  • Origin pengunjung diteruskan, karena fetch sisi server tidak mengirimnya dan Ikiform membacanya untuk mengenali uji gratis dari localhost.
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.

Widget sebagai Svelte action

src/lib/turnstile.ts
const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";

interface Api {
  render(container: HTMLElement, options: { sitekey: string; action: string }): string;
  reset(widgetId: string): void;
  remove(widgetId: string): void;
}

let loading: Promise<Api> | undefined;

function load() {
  loading ??= new Promise<Api>((resolve, reject) => {
    const script = document.createElement("script");
    script.src = SCRIPT;
    script.async = true;
    script.onload = () => {
      const api = (window as Window & { turnstile?: Api }).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 turnstile(node: HTMLElement, sitekey: string) {
  let api: Api | undefined;
  load().then((loaded) => {
    api = loaded;
    node.dataset.widgetId = loaded.render(node, { sitekey, action: "contact" });
  });
  return {
    destroy() {
      if (node.dataset.widgetId) api?.remove(node.dataset.widgetId);
    },
  };
}

export async function resetTurnstile(node: HTMLElement | undefined) {
  const id = node?.dataset.widgetId;
  if (id) (await load()).reset(id);
}

Svelte action adalah tempat yang alami untuk widget pihak ketiga: berjalan saat elemen di-mount, dan destroy berjalan saat elemen dilepas. ID widget disimpan di elemen, sehingga halaman bisa me-reset widget yang tepat.

Halamannya

src/routes/contact/+page.svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import { PUBLIC_TURNSTILE_SITE_KEY } from "$app/env/public";
  import { resetTurnstile, turnstile } from "#lib/turnstile.js";
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
  let box = $state<HTMLDivElement>();
  let sending = $state(false);
</script>

{#if form?.sent}
  <p role="status">Thanks. We'll reply by email.</p>
{:else}
  <form
    method="POST"
    use:enhance={() => {
      sending = true;
      return async ({ update }) => {
        await update({ reset: false });
        sending = false;
        await resetTurnstile(box);
      };
    }}
  >
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required maxlength="120" value={form?.values?.name ?? ""} />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required value={form?.values?.email ?? ""} />
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="5" required value={form?.values?.message ?? ""}></textarea>
    <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 bind:this={box} use:turnstile={PUBLIC_TURNSTILE_SITE_KEY}></div>
    {#if form?.error}
      <p role="alert">{form.error}</p>
    {/if}
    <button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
  </form>
{/if}
  • update({ reset: false }) menerapkan hasilnya dan mempertahankan ketikan pengunjung. Default-nya akan mengosongkan formulir, seperti yang dilakukan browser.
  • resetTurnstile berjalan setelah setiap jawaban, berhasil atau tidak. Token yang terpakai hilang dan token baru sudah siap.
  • Tanpa JavaScript, formulir tetap terkirim dan action tetap berjalan, tetapi widget tidak dirender, jadi siteverify menolaknya dengan pesan yang jelas.
  • Halaman dengan form action tidak bisa di-prerender, jadi route ini selalu dirender di server. Halaman lain tetap bisa di-prerender.
  • Class .hp menyembunyikan honeypot di luar layar, seperti di panduan honeypot.

Uji secara lokal

Di vite dev tidak ada adapter di depan aplikasi, jadi getClientAddress() mengembalikan alamat loopback Anda dan setiap tab lokal berbagi satu bucket. Itu cukup untuk memeriksa pesannya, tetapi tidak untuk memeriksa setup proxy. Untuk mengujinya, build dengan adapter-node dan jalankan hasilnya dengan variabel yang sama yang akan Anda pakai di production:

Terminal
npm run build
ADDRESS_HEADER=X-Forwarded-For XFF_DEPTH=1 node build

Kirim beberapa kali dengan nilai X-Forwarded-For yang berbeda. Dengan XFF_DEPTH=1, SvelteKit membaca alamat paling kanan, yang akan ditambahkan proxy Anda, jadi nilai karangan client di sebelah kiri tidak mengubah apa pun.

Deploy

Di server Anda sendiri dengan adapter-node di belakang satu proxy, beri tahu SvelteKit di mana IP aslinya:

Environment
ADDRESS_HEADER=X-Forwarded-For
XFF_DEPTH=1

Dokumentasi SvelteKit memperingatkan bahwa header mudah dipalsukan dan bahwa membaca alamat X-Forwarded-For paling kiri adalah kesalahan klasik. XFF_DEPTH menghitung proxy tepercaya dari kanan, jadi atur ke jumlah proxy yang Anda jalankan, dan pastikan aplikasi tidak bisa dijangkau tanpa melewatinya. SvelteKit 3 menghapus variabel ORIGIN di adapter-node. Jika proxy Anda tidak meneruskan host asli, atur paths.origin di opsi sveltekit() agar pemeriksaan formulir milik SvelteKit mengetahui URL publik Anda.

AdapterIP clientSecret
adapter-nodeADDRESS_HEADER dan XFF_DEPTHEnvironment proses
adapter-vercelOtomatisEnvironment variable proyek
adapter-netlifyOtomatisEnvironment variable situs
adapter-cloudflareOtomatiswrangler secret put

Biarkan Ikiform menjalankan pemeriksaannya

Agar tidak perlu menjalankan semua ini sendiri, hapus +page.server.ts dan arahkan action formulir ke endpoint Ikiform Anda, tanpa use:enhance. Halaman lalu bisa di-prerender seperti sisa situs statis.

Atur Spam check formulir ke Ikiform, agar kiriman tanpa token mendapat halaman pemeriksaan singkat di ikiform.com yang menjalankan Turnstile dan meneruskan field-nya. 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, jadi false positive bisa dipulihkan dengan satu klik. Panduan setup SvelteKit membahas endpoint dan key-nya.

Pastikan berfungsi

  1. Jalankan vite dev dengan test key dan kirim pesan. Pesan muncul di Submissions. Workspace tanpa paket menerima 50 uji dari localhost per bulan.
  2. Kirim pesan kedua tanpa memuat ulang. Pesan berhasil, yang menunjukkan reset widget bekerja.
  3. Kirim enam pesan dalam satu menit. Pesan keenam menampilkan berapa detik harus menunggu.
  4. Di belakang proxy Anda, catat getClientAddress() di log dari dua jaringan. Anda melihat dua alamat berbeda, bukan alamat proxy 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
Semua pengunjung terkena rate limit bersama-sama di adapter-nodegetClientAddress() mengembalikan alamat proxy Anda. Atur ADDRESS_HEADER dan XFF_DEPTH.
Kiriman kedua selalu gagal pemeriksaan manusiaWidget masih memegang token yang sudah terpakai. Panggil resetTurnstile di callback use:enhance setelah update.
Build gagal karena halaman dengan action di-prerenderRoute kontak mewarisi prerender = true dari layout. Halaman dengan action tidak bisa di-prerender; atur export const prerender = false di +page.server.ts-nya.
Area widget kosong dan console melaporkan pelanggaran CSPscript-src atau frame-src tidak memuat https://challenges.cloudflare.com di csp.directives dalam vite.config.ts.

Pertanyaan

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