Hentikan spam formulir kontak

Hentikan spam formulir kontak7 menit baca

Perlindungan spam formulir React Router dengan Turnstile

Lindungi formulir React Router dari spam: route action dengan Turnstile, rate limit Workers berdasarkan cf-connecting-ip, modul .server, dan header CSP.

Preet SutharPendiri, Ikiform

Lakukan pemeriksaan di action route: batasi berdasarkan IP client, verifikasi token Turnstile dengan siteverify, lalu teruskan field-nya bersama honeypot. Simpan helper di modul .server.ts agar tidak pernah sampai ke browser, dan atur CSP dari export headers route. Di Cloudflare Workers, binding rate limiting dan secret berasal dari env di cloudflare:workers.

Apa yang berbeda di React Router

Dalam framework mode, setiap route yang punya action menerima POST, dari formulir Anda atau dari siapa pun. React Router memberi Anda tiga alat yang menentukan bagaimana pemeriksaan spam ditempatkan:

  • Modul .server. File bernama spam.server.ts tidak bisa di-bundle ke kode client, jadi secret dan panggilan siteverify tetap di server meskipun komponen mengimpor dari route yang sama.
  • Export headers. Header yang dikembalikan action tidak dikirim di respons dokumen kecuali fungsi headers route meneruskannya. CSP juga ditaruh di sana.
  • useFetcher. Hook ini mengirim ke action tanpa navigasi, jadi halaman dan widget-nya tetap ter-mount di antara percobaan. Itulah sebabnya widget di-mount ulang dengan key setelah setiap jawaban.

Halaman ini di-deploy ke Cloudflare Workers, tempat yang umum untuk React Router, yang sudah punya binding rate limiting bawaan. Tabel deploy membahas Vercel dan Netlify.

Bagaimana kiriman sampai ke action React Router, dengan dan tanpa JavaScriptSetelah hydration: useFetcherSebelum hydration: POST dokumenfetcher.Formtanpanavigasiactionlimit,siteverify,teruskanfetcher.dataerror atauterima kasihwidgetbarukey berubahkirimanformulirbiasawidget tidakdirenderactionsiteverifymenolakexportheadersCSP,Retry-AfteractionDataerrorditampilkanPOSTdata()remountPOSTrenderBagaimana kiriman sampai ke action React Router, dengan dan tanpa JavaScriptSetelah hydration: useFetcherSebelum hydration: POST dokumenfetcher.Formtanpa navigasiactionlimit, siteverify, teruskanfetcher.dataerror atau terima kasihwidget barukey berubahkiriman formulir biasawidget tidak direnderactionsiteverify menolakexport headersCSP, Retry-AfteractionDataerror ditampilkanPOSTdata()remountPOSTrender
  1. Setelah hydration, formulir mengirim lewat useFetcher, sehingga halaman tidak bernavigasi.
  2. Action membatasi per IP, memverifikasi token Turnstile, dan meneruskan field-nya.
  3. Hasilnya tiba sebagai fetcher.data.
  4. Key widget berubah, sehingga widget baru, dan token baru, siap untuk percobaan berikutnya.
  5. Sebelum hydration, formulir yang sama mengirim sebagai request dokumen biasa, tanpa token Turnstile.
  6. Action menolaknya di siteverify.
  7. Export headers meneruskan Retry-After dan CSP ke respons dokumen.
  8. Komponen membaca actionData alih-alih fetcher.data dan menampilkan error-nya.
Pemeriksaan manusia butuh JavaScript, jadi jalur tanpa JavaScript berakhir dengan error yang jelas, bukan lolos diam-diam.

Setup proyek

Mulai dari template React Router milik Cloudflare dengan @cloudflare/vite-plugin. Di React Router 8 argumen context selalu berupa RouterContextProvider, dan objek biasa { cloudflare: { env } } dari entry Worker akan melempar error, jadi entry hanya meneruskan request dan route membaca binding dengan import { env } from "cloudflare:workers". File yang disentuh halaman ini:

File
app/
  routes/
    contact.tsx
  components/
    turnstile.tsx
  lib/
    spam.server.ts
workers/
  app.ts
wrangler.jsonc
.dev.vars
workers/app.ts
import { createRequestHandler } from "react-router";

const handler = createRequestHandler(() => import("virtual:react-router/server-build"), import.meta.env.MODE);

export default {
  fetch(request) {
    return handler(request);
  },
} satisfies ExportedHandler<Env>;
wrangler.jsonc
{
  "name": "acme-site",
  "main": "./workers/app.ts",
  "vars": { "TURNSTILE_SITE_KEY": "1x00000000000000000000AA" },
  "ratelimits": [
    {
      "name": "CONTACT_LIMIT",
      "namespace_id": "1001",
      "simple": { "limit": 5, "period": 60 }
    }
  ]
}
.dev.vars
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
  • Site key bersifat publik, jadi diletakkan di vars. Loader memberikannya ke komponen, sehingga build client sama sekali tidak butuh environment variable.
  • Secret disimpan di .dev.vars secara lokal dan lewat wrangler secret put di production, jangan pernah di wrangler.jsonc.
  • Jalankan wrangler types setelah mengedit konfigurasi, agar CONTACT_LIMIT dan secret punya tipe di env.

Helper khusus server

app/lib/spam.server.ts
export async function isHuman(secret: string, token: string, ip: string, host: string) {
  if (!token || token.length > 2048) return false;
  const body = new URLSearchParams({ secret, response: token, remoteip: ip });
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body });
    const verdict = (await response.json()) as { success: boolean; hostname?: string; action?: string };
    return verdict.success && verdict.action === "contact" && (import.meta.env.DEV || verdict.hostname === host);
  } catch {
    return false;
  }
}

export async function forward(secretKey: string, form: FormData, origin: string | null) {
  const headers = new Headers({ Accept: "application/json", Authorization: `Bearer ${secretKey}` });
  if (origin) headers.set("Origin", origin);
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", { method: "POST", headers, body: form }).catch(() => null);
  return response?.ok ?? false;
}
  • Akhiran .server membuat build gagal jika kode client akan menyertakan modul ini.
  • Siteverify menerima body form-encoded. Pemeriksaan hostname membandingkan dengan host yang dituju pengunjung dan dilewati saat development, karena token uji membawa hostname tiruan.
  • forward mengirim FormData aslinya, sehingga setiap field, termasuk honeypot, sampai ke Ikiform persis seperti yang dikirim.
  • Helper ini juga meneruskan Origin pengunjung, 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

app/components/turnstile.tsx
import { useEffect, useRef } from "react";

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

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

declare global {
  interface Window {
    turnstile?: TurnstileApi;
  }
}

export function Turnstile({ siteKey }: { siteKey: string }) {
  const box = useRef<HTMLDivElement>(null);

  useEffect(() => {
    let widgetId: string | undefined;
    const mount = () => {
      if (box.current && window.turnstile) widgetId = window.turnstile.render(box.current, { sitekey: siteKey, action: "contact" });
    };
    if (window.turnstile) mount();
    else document.querySelector(`script[src="${SCRIPT}"]`)?.addEventListener("load", mount, { once: true });
    return () => {
      if (widgetId) window.turnstile?.remove(widgetId);
    };
  }, [siteKey]);

  return (
    <>
      <script src={SCRIPT} async />
      <div ref={box} />
    </>
  );
}

React 19 mengangkat <script> async ke head dan memuatnya sekali, berapa kali pun komponen dirender. Effect merender widget secara eksplisit, yang direkomendasikan Cloudflare untuk single-page app, dan menghapusnya saat unmount.

Route

app/routes/contact.tsx
import { useEffect, useState } from "react";
import { data, useFetcher } from "react-router";
import { env } from "cloudflare:workers";
import { Turnstile } from "~/components/turnstile";
import { forward, isHuman } from "~/lib/spam.server";
import type { Route } from "./+types/contact";

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 function loader() {
  return { siteKey: env.TURNSTILE_SITE_KEY };
}

export async function action({ request }: Route.ActionArgs) {
  const ip = request.headers.get("cf-connecting-ip") ?? "unknown";

  const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
  if (!success) {
    return data({ ok: false, error: "Too many messages. Try again in a minute." }, { status: 429, headers: { "Retry-After": "60" } });
  }

  const form = await request.formData();
  const token = String(form.get("cf-turnstile-response") ?? "");
  if (!(await isHuman(env.TURNSTILE_SECRET_KEY, token, ip, new URL(request.url).hostname))) {
    return data({ ok: false, error: "We couldn't confirm you're human. Try again." }, { status: 403 });
  }

  form.delete("cf-turnstile-response");
  const sent = await forward(env.IKIFORM_SECRET_KEY, form, request.headers.get("origin"));
  return sent ? { ok: true, error: "" } : data({ ok: false, error: "Your message didn't send. Try again." }, { status: 502 });
}

export function headers({ actionHeaders, parentHeaders }: Route.HeadersArgs) {
  parentHeaders.set("Content-Security-Policy", POLICY);
  const retry = actionHeaders.get("Retry-After");
  if (retry) parentHeaders.set("Retry-After", retry);
  return parentHeaders;
}

export default function Contact({ loaderData, actionData }: Route.ComponentProps) {
  const fetcher = useFetcher<typeof action>();
  const result = fetcher.data ?? actionData;
  const [round, setRound] = useState(0);

  useEffect(() => {
    if (fetcher.state === "idle" && fetcher.data) setRound((count) => count + 1);
  }, [fetcher.state, fetcher.data]);

  if (result?.ok) return <p role="status">Thanks. We'll reply by email.</p>;

  return (
    <fetcher.Form method="post">
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} />
      <div className="hp" aria-hidden="true">
        <label htmlFor="hp-field">Leave this empty</label>
        <input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
      </div>
      <Turnstile key={round} siteKey={loaderData.siteKey} />
      {result?.error ? <p role="alert">{result.error}</p> : null}
      <button disabled={fetcher.state !== "idle"}>{fetcher.state === "idle" ? "Send message" : "Sending"}</button>
    </fetcher.Form>
  );
}
  • Limit berjalan sebelum request.formData(), jadi banjir request tidak pernah memakan parsing body atau panggilan siteverify.
  • cf-connecting-ip diatur Cloudflare di setiap request ke Worker dan tidak bisa diatur pengunjung.
  • data() mengatur status dan Retry-After. Fetcher membaca body secara langsung; export headers yang membawa header tersebut ke respons dokumen.
  • result kembali ke actionData, sehingga error tetap tampil saat formulir dikirim sebelum hydration.
  • Class .hp menyembunyikan honeypot di luar layar, seperti di panduan honeypot.

React Router merender skrip inline untuk hydration, jadi policy ini mempertahankan 'unsafe-inline' di script-src. Untuk menghapusnya, buat nonce di entry.server.tsx dan teruskan ke <Scripts> dan skrip Turnstile; Cloudflare menyatakan Turnstile meneruskan nonce ke apa yang dimuatnya.

Uji secara lokal

Dokumentasi Cloudflare menyatakan plugin Vite-nya menjalankan kode server Anda di runtime Workers selama development, jadi env dari cloudflare:workers, .dev.vars, dan binding rate limiting berperilaku seperti di production. Secara lokal tidak ada edge Cloudflare yang mengatur cf-connecting-ip, jadi setiap request dihitung sebagai unknown dan berbagi satu limit. Itu memang diharapkan. Untuk melihat header aslinya, deploy preview dengan wrangler versions upload dan uji dari dua jaringan.

Deploy ke tempat lain

HostingIP clientLimiterSecret
Cloudflare Workerscf-connecting-ipBinding rate limitingwrangler secret put
Vercelx-forwarded-for, ditimpa oleh VercelRedisprocess.env
Netlifyx-nf-client-connection-ipRedisprocess.env

Cloudflare mendokumentasikan binding ini sebagai perkiraan dan dihitung per lokasi, yang tidak masalah untuk formulir kontak. Di luar Workers, tidak ada modul cloudflare:workers, jadi baca secret dari process.env dan ganti binding dengan penghitung Redis seperti di halaman rate limiting.

Biarkan Ikiform menjalankan pemeriksaannya

Jika mengelola key, binding, dan CSP untuk satu formulir terasa berlebihan, arahkan formulir biasa ke endpoint Ikiform Anda dan hapus action-nya:

app/routes/contact.tsx
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label htmlFor="email">Email</label>
  <input id="email" name="email" type="email" autoComplete="email" required />
  <label htmlFor="message">Message</label>
  <textarea id="message" name="message" rows={5} required />
  <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
  <button>Send message</button>
</form>

Pakai <form> huruf kecil, bukan <Form> milik React Router, agar browser mengirim langsung ke Ikiform. Atur Spam check ke Ikiform: 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. Spam yang tertangkap mendapat jawaban yang sama dengan pesan asli dan disimpan selama 30 hari. Panduan setup server membahas cara tetap memakai action Anda sendiri.

Pastikan berfungsi

  1. Jalankan npm run dev dan kirim pesan dengan test key. Pesan muncul di Submissions. Workspace tanpa paket menerima 50 uji dari localhost per bulan.
  2. Kirim pesan kedua tanpa memuat ulang. Widget baru muncul setelah jawaban pertama dan kiriman kedua berhasil.
  3. Matikan JavaScript dan kirim. Halaman dimuat ulang dengan error pemeriksaan manusia.
  4. Kirim enam kali dalam satu menit setelah deploy. Kiriman keenam mendapat 429 dengan Retry-After: 60.
  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
Server-only module referenced by clientSebuah komponen mengimpor sesuatu dari spam.server.ts yang direndernya. Hanya loader dan action yang boleh memakainya.
Percobaan kedua selalu gagal di siteverifyWidget masih memegang token yang sudah terpakai. Pastikan key-nya berubah setelah setiap jawaban fetcher.
Invalid context value provided to handleRequestEntry Worker masih meneruskan { cloudflare: { env, ctx } }, seperti template React Router 7. Panggil handler hanya dengan request dan baca binding dari cloudflare:workers.
Retry-After tidak ada di respons tanpa JavaScriptHeader action dibuang kecuali export headers mengembalikannya. Salin dari actionHeaders seperti yang ditunjukkan.

Pertanyaan

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