Hentikan spam formulir kontak

Hentikan spam formulir kontak7 menit baca

Perlindungan spam formulir TanStack Start dengan Turnstile

Lindungi formulir TanStack Start dari spam: function middleware untuk IP client, token bucket, Turnstile yang diverifikasi di server function, dan CSP.

Preet SutharPendiri, Ikiform

Tambahkan function middleware yang membaca IP client dan origin sekali lalu meneruskannya ke setiap server function sebagai context. Di server function, validasi input, ambil satu token dari bucket per IP, verifikasi token Turnstile dengan siteverify, lalu teruskan field-nya bersama honeypot. Pertahankan middleware CSRF yang dipasang Start, dan tambahkan kembali sendiri jika Anda membuat src/start.ts.

Apa yang berbeda di TanStack Start

Server function adalah endpoint RPC same-origin. Start melindunginya dari situs lain dengan middleware CSRF yang memeriksa Sec-Fetch-Site, Origin, atau Referer, dan itu mencegah halaman di tempat lain mengirim lewat browser pengunjung. Tetapi tidak menghentikan skrip, yang bisa mengirim header apa saja. Jadi pemeriksaan spam tetap harus ada di dalam function.

Blok bangunan Start untuk ini adalah middleware. Function middleware berjalan sebelum validator dan handler, bisa membaca header request, dan memberikan nilai ke handler lewat context. Tulis pencarian IP sekali dan setiap formulir di situs ikut mendapatkannya.

Rantai middleware di depan server function TanStack StartSebelum kode AndaDi server functionPOST/_serverFndariformuliratau skripmiddlewareCSRForiginsama?middlewarepengunjungcontext:ip, originvalidatortrim,batasipanjangtokenbucket3 coba, +1tiap 20dtksiteverifyactionadalahsupportIkiformsimpan,skorhoneypothasil:cobanantiyatoken tersisamanusiakosongRantai middleware di depan server function TanStack StartSebelum kode AndaDi server functionPOST/_serverFndari formulir atauskripmiddlewareCSRForigin sama?middlewarepengunjungcontext: ip, originvalidatortrim, batasi panjangtoken bucket3 coba, +1 tiap 20dtksiteverifyaction adalahsupportIkiformsimpan, skorhoneypothasil:cobanantiyatoken tersisamanusiakosong
  1. Browser, atau skrip, mengirim ke URL server function.
  2. Middleware CSRF milik Start menolak kiriman yang tidak bisa dibuktikan berasal dari origin Anda sendiri.
  3. Middleware pengunjung membaca IP client dan header Origin lalu meneruskannya sebagai context.
  4. Validator menormalkan input sebelum handler melihatnya.
  5. Handler mengambil satu token dari bucket IP tersebut. Jika habis, handler mengembalikan hasil coba-lagi-nanti.
  6. Handler memverifikasi token Turnstile, lalu meneruskan field ke Ikiform, yang menyimpannya dan memberi skor pada honeypot.
Setiap lapisan lebih murah dari lapisan berikutnya. Kiriman yang ditolak tidak pernah sampai ke siteverify atau Ikiform.

Setup proyek

File
src/
  routes/
    support.tsx
  lib/
    use-turnstile.ts
  server/
    bucket.ts
    visitor.ts
    support.ts
  start.ts
.env
.env
VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
CLIENT_IP_HEADER=x-forwarded-for
  • Vite hanya membuka variabel berawalan VITE_ ke browser, dibaca lewat import.meta.env. Secret tidak punya awalan itu, jadi hanya ada di server sebagai process.env.
  • Test key lolos di host mana pun, termasuk localhost. Ganti dengan key asli per environment.

Function middleware untuk IP client

src/server/visitor.ts
import { createMiddleware } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";

const HEADER = process.env.CLIENT_IP_HEADER ?? "x-forwarded-for";

export const visitor = createMiddleware({ type: "function" }).server(async ({ next }) => {
  const ip = getRequestHeader(HEADER)?.split(",")[0]?.trim() || "unknown";
  return next({ context: { ip, origin: getRequestHeader("origin") ?? null } });
});

Atur CLIENT_IP_HEADER ke header yang ditimpa hosting Anda: x-forwarded-for di Vercel, x-nf-client-connection-ip di Netlify, yang direkomendasikan staf Netlify meski dokumentasinya hanya menjelaskan context.ip di Functions, dan cf-connecting-ip di Cloudflare. Di server Node Anda sendiri di belakang nginx, pakai header yang diatur nginx dan pastikan aplikasi tidak bisa dijangkau tanpa melewatinya. Middleware juga meneruskan Origin pengunjung, karena fetch sisi server ke Ikiform tidak mengirimnya dan Ikiform membacanya untuk mengenali uji gratis dari localhost.

Token bucket per IP

Jendela tetap membiarkan skrip mengirim jatah penuh di akhir satu menit dan jatah lain di awal menit berikutnya. Token bucket mengisi ulang satu percobaan setiap kali, jadi orang yang memperbaiki salah ketik hanya menunggu beberapa detik, bukan satu menit:

src/server/bucket.ts
const CAPACITY = 3;
const REFILL_MS = 20_000;
const buckets = new Map<string, { tokens: number; at: number }>();

export function take(key: string, now = Date.now()) {
  const saved = buckets.get(key) ?? { tokens: CAPACITY, at: now };
  const tokens = Math.min(CAPACITY, saved.tokens + (now - saved.at) / REFILL_MS);
  if (tokens < 1) {
    buckets.set(key, { tokens, at: now });
    return { ok: false, retryAfter: Math.ceil(((1 - tokens) * REFILL_MS) / 1000) };
  }
  if (buckets.size > 50_000) buckets.clear();
  buckets.set(key, { tokens: tokens - 1, at: now });
  return { ok: true, retryAfter: 0 };
}

Penghitung ini disimpan di memori, yang akurat di satu server yang berjalan lama, seperti node .output/server/index.mjs di container. Di Vercel atau Netlify setiap instance function menyimpan map-nya sendiri, jadi limit sebenarnya tidak diketahui. Pindahkan bucket ke Redis di sana, seperti yang ditunjukkan halaman rate limiting, dan pertahankan bentuk take(key) yang sama.

Server function

src/server/support.ts
import { createServerFn } from "@tanstack/react-start";
import { take } from "./bucket";
import { visitor } from "./visitor";

const TOPICS = ["billing", "bug", "account", "other"];
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export interface SupportInput {
  email: string;
  topic: string;
  message: string;
  _gotcha: string;
  token: string;
}

export type SupportResult = { ok: true } | { ok: false; field?: "email" | "message"; message: string };

async function isHuman(token: string, ip: string) {
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: token, remoteip: ip }),
    });
    const verdict = (await response.json()) as { success: boolean; action?: string };
    return verdict.success && verdict.action === "support";
  } catch {
    return false;
  }
}

export const sendSupportRequest = createServerFn({ method: "POST" })
  .middleware([visitor])
  .validator((input: SupportInput) => ({
    email: String(input.email ?? "").trim(),
    topic: TOPICS.includes(input.topic) ? input.topic : "other",
    message: String(input.message ?? "").trim().slice(0, 5000),
    _gotcha: String(input._gotcha ?? ""),
    token: String(input.token ?? "").slice(0, 2048),
  }))
  .handler(async ({ data, context }): Promise<SupportResult> => {
    if (!EMAIL.test(data.email)) return { ok: false, field: "email", message: "Enter an address we can reply to." };
    if (data.message.length < 10) return { ok: false, field: "message", message: "Tell us a little more." };

    const limit = take(context.ip);
    if (!limit.ok) return { ok: false, message: `Too many requests. Try again in ${limit.retryAfter} seconds.` };
    if (!(await isHuman(data.token, context.ip))) return { ok: false, message: "We couldn't confirm you're human." };

    const { token: _token, ...fields } = data;
    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
        ...(context.origin ? { Origin: context.origin } : {}),
      },
      body: JSON.stringify(fields),
    }).catch(() => null);
    return response?.ok ? { ok: true } : { ok: false, message: "Your request didn't send. Try again." };
  });
  • Validator berjalan sebelum handler dan hanya menormalkan input. Error field kembali sebagai hasil yang bisa ditampilkan formulir di samping input yang tepat.
  • Pemeriksaan murah lebih dulu: pemeriksaan email dan panjang tidak memakan biaya, bucket memakan satu pencarian map, siteverify memakan satu panggilan jaringan.
  • action harus support. Token yang dibuat untuk widget lain dengan site key Anda akan ditolak.
  • Honeypot _gotcha diteruskan, tidak diperiksa di sini, sehingga honeypot yang terisi disimpan sebagai spam di tempat Anda bisa meninjaunya.
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.

Route dan widget

Widget dirender secara eksplisit dari hook kecil. Promise di level modul memuat skrip sekali, bahkan saat route di-mount beberapa kali selama navigasi client, dan token tiba lewat callback Turnstile:

src/lib/use-turnstile.ts
import { useCallback, useEffect, useRef, useState } from "react";

interface Api {
  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<Api> | undefined;

function load() {
  loading ??= new Promise<Api>((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?: 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 useTurnstile(sitekey: string, action: string) {
  const ref = useRef<HTMLDivElement>(null);
  const api = useRef<Api | null>(null);
  const widget = useRef<string | null>(null);
  const [token, setToken] = useState("");

  useEffect(() => {
    let live = true;
    load().then((turnstile) => {
      if (!live || !ref.current) return;
      api.current = turnstile;
      widget.current = turnstile.render(ref.current, {
        sitekey,
        action,
        callback: setToken,
        "expired-callback": () => setToken(""),
      });
    });
    return () => {
      live = false;
      if (widget.current) api.current?.remove(widget.current);
      widget.current = null;
    };
  }, [sitekey, action]);

  const reset = useCallback(() => {
    setToken("");
    if (widget.current) api.current?.reset(widget.current);
  }, []);

  return { ref, token, reset };
}
src/routes/support.tsx
import { createFileRoute } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { type FormEvent, useState } from "react";
import { useTurnstile } from "../lib/use-turnstile";
import { type SupportResult, sendSupportRequest } from "../server/support";

export const Route = createFileRoute("/support")({ component: Support });

function Support() {
  const send = useServerFn(sendSupportRequest);
  const turnstile = useTurnstile(import.meta.env.VITE_TURNSTILE_SITE_KEY, "support");
  const [result, setResult] = useState<SupportResult | null>(null);
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const form = new FormData(event.currentTarget);
    setPending(true);
    try {
      setResult(
        await send({
          data: {
            email: String(form.get("email") ?? ""),
            topic: String(form.get("topic") ?? ""),
            message: String(form.get("message") ?? ""),
            _gotcha: String(form.get("_gotcha") ?? ""),
            token: turnstile.token,
          },
        }),
      );
    } catch {
      setResult({ ok: false, message: "Couldn't reach the server. Try again." });
    } finally {
      setPending(false);
      turnstile.reset();
    }
  }

  if (result?.ok) return <p role="status">Got it. We'll reply by email within a day.</p>;
  const error = result && !result.ok ? result : null;

  return (
    <form onSubmit={onSubmit} noValidate>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" aria-invalid={error?.field === "email"} />
      <label htmlFor="topic">Topic</label>
      <select id="topic" name="topic" defaultValue="other">
        <option value="billing">Billing</option>
        <option value="bug">Something is broken</option>
        <option value="account">My account</option>
        <option value="other">Something else</option>
      </select>
      <label htmlFor="message">What happened?</label>
      <textarea id="message" name="message" rows={6} aria-invalid={error?.field === "message"} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" aria-hidden="true" className="hp" />
      <div ref={turnstile.ref} />
      {error ? <p role="alert">{error.message}</p> : null}
      <button disabled={pending || !turnstile.token}>{pending ? "Sending" : "Send request"}</button>
    </form>
  );
}
  • Tombol tetap nonaktif sampai Turnstile memberikan token, sehingga tidak ada yang mengirim token kosong.
  • turnstile.reset() berjalan setelah setiap percobaan. Token hanya berlaku sekali dan hidup selama 300 detik, jadi percobaan ulang butuh token baru.
  • Formulir ini butuh JavaScript. Server function bisa menerima kiriman formulir biasa lewat .url-nya, tetapi widget Turnstile memang butuh browser.

Header keamanan di src/start.ts

Start tidak punya opsi CSP sendiri. Request middleware global di src/start.ts bisa memasangnya di setiap respons. Membuat file itu mematikan middleware CSRF yang dipasang Start secara default, jadi tambahkan kembali terlebih dulu:

src/start.ts
import { createCsrfMiddleware, createMiddleware, createStart } from "@tanstack/react-start";
import { setResponseHeaders } from "@tanstack/react-start/server";

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'",
  "frame-ancestors 'none'",
].join("; ");

const securityHeaders = createMiddleware().server(async ({ next }) => {
  setResponseHeaders(new Headers({ "Content-Security-Policy": policy }));
  return next();
});

export const startInstance = createStart(() => ({
  requestMiddleware: [createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" }), securityHeaders],
}));
  • HTML hasil server-render Start memuat skrip inline. Tanpa setup nonce, script-src butuh 'unsafe-inline'; policy ini tetap memblokir skrip dari host mana pun yang tidak Anda cantumkan.
  • Turnstile menggambar widget-nya di iframe dari challenges.cloudflare.com, yang harus diizinkan oleh frame-src.
  • filter CSRF membatasi pemeriksaan hanya pada server function, jadi pemuatan halaman biasa tidak terpengaruh.

Deploy

HostingCLIENT_IP_HEADERLimiter
Server Node atau container Anda sendiriApa pun yang diatur proxy AndaMemori sudah cukup
Vercelx-forwarded-forRedis
Netlifyx-nf-client-connection-ipRedis
Cloudflare Workerscf-connecting-ipBinding rate limiting Workers

Vercel mendokumentasikan bahwa ia menimpa x-forwarded-for dengan alamat pengunjung. Daftarkan setiap hostname production dan preview di widget Turnstile sebelum deploy.

Biarkan Ikiform menjalankan pemeriksaannya

Jika Anda tidak ingin mengelola key Turnstile dan limiter sendiri, hapus server function dan kirim dari browser. Di onSubmit, ganti panggilan send dengan challenge dan submit, dengan box sebagai ref pada div kosong yang menggantikan widget:

src/routes/support.tsx
import { challenge, submit } from "@ikiform/sdk";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

const turnstileToken = await challenge(FORM_ID, { container: box.current ?? undefined });
const result = await submit(FORM_ID, new FormData(event.currentTarget), { turnstileToken });

Atur Spam check formulir ke Ikiform, pilih Rate limit Strict 2 per 10 menit per pengunjung, dan tambahkan domain Anda di Allowed domains. Ikiform lalu menjalankan honeypot, Turnstile di ikiform.com, limit per IP, dan skor konten, serta menyimpan yang tertangkap selama 30 hari. CSP Anda butuh https://ikiform.com di frame-src dan connect-src, bukan Cloudflare. Panduan setup server membahas key jika Anda tetap memakai server function.

Pastikan berfungsi

  1. Jalankan dev server dengan test key dan kirim request. Request muncul di Submissions. Tanpa paket, 50 uji dari localhost per bulan gratis.
  2. Kirim empat request dengan cepat. Yang keempat mengembalikan pesan coba-lagi-nanti dengan jumlah detik.
  3. Atur TURNSTILE_SECRET_KEY ke secret yang selalu gagal. Setiap request menampilkan error pemeriksaan manusia.
  4. Ulangi panggilan server function dari tab network dengan curl dan tanpa Origin. Middleware CSRF menolaknya.
  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
Kiriman lintas situs sampai ke server function setelah menambahkan src/start.tssrc/start.ts kustom menggantikan middleware CSRF default. Tambahkan createCsrfMiddleware() ke requestMiddleware, seperti yang ditunjukkan.
Semua pengunjung berbagi satu bucketCLIENT_IP_HEADER menyebut header yang tidak diatur hosting Anda, sehingga setiap IP menjadi unknown. Pakai header dari tabel di atas.
Limit tampak ter-reset secara acak di VercelSetiap instance function punya memorinya sendiri. Pindahkan bucket ke Redis.
Widget dirender dua kali saat developmentStrict Mode React me-mount effect dua kali. Cleanup menghapus widget pertama, jadi production hanya menampilkan satu.

Pertanyaan

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