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.
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 bernamaspam.server.tstidak 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 fungsiheadersroute 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 dengankeysetelah 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.
- Setelah hydration, formulir mengirim lewat useFetcher, sehingga halaman tidak bernavigasi.
- Action membatasi per IP, memverifikasi token Turnstile, dan meneruskan field-nya.
- Hasilnya tiba sebagai fetcher.data.
- Key widget berubah, sehingga widget baru, dan token baru, siap untuk percobaan berikutnya.
- Sebelum hydration, formulir yang sama mengirim sebagai request dokumen biasa, tanpa token Turnstile.
- Action menolaknya di siteverify.
- Export headers meneruskan Retry-After dan CSP ke respons dokumen.
- Komponen membaca actionData alih-alih fetcher.data dan menampilkan error-nya.
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:
app/
routes/
contact.tsx
components/
turnstile.tsx
lib/
spam.server.ts
workers/
app.ts
wrangler.jsonc
.dev.varsimport { 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>;{
"name": "acme-site",
"main": "./workers/app.ts",
"vars": { "TURNSTILE_SITE_KEY": "1x00000000000000000000AA" },
"ratelimits": [
{
"name": "CONTACT_LIMIT",
"namespace_id": "1001",
"simple": { "limit": 5, "period": 60 }
}
]
}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.varssecara lokal dan lewatwrangler secret putdi production, jangan pernah diwrangler.jsonc. - Jalankan
wrangler typessetelah mengedit konfigurasi, agarCONTACT_LIMITdan secret punya tipe dienv.
Helper khusus server
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
.servermembuat 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.
forwardmengirimFormDataaslinya, sehingga setiap field, termasuk honeypot, sampai ke Ikiform persis seperti yang dikirim.- Helper ini juga meneruskan
Originpengunjung, karena fetch sisi server tidak mengirimnya dan Ikiform membacanya untuk mengenali uji gratis darilocalhost.
IKIFORM_SECRET_KEY di environment variable khusus server.Widget
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
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-ipdiatur Cloudflare di setiap request ke Worker dan tidak bisa diatur pengunjung.data()mengatur status danRetry-After. Fetcher membaca body secara langsung; exportheadersyang membawa header tersebut ke respons dokumen.resultkembali keactionData, sehingga error tetap tampil saat formulir dikirim sebelum hydration.- Class
.hpmenyembunyikan 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
| Hosting | IP client | Limiter | Secret |
|---|---|---|---|
| Cloudflare Workers | cf-connecting-ip | Binding rate limiting | wrangler secret put |
| Vercel | x-forwarded-for, ditimpa oleh Vercel | Redis | process.env |
| Netlify | x-nf-client-connection-ip | Redis | process.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:
<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
- Jalankan
npm run devdan kirim pesan dengan test key. Pesan muncul di Submissions. Workspace tanpa paket menerima 50 uji darilocalhostper bulan. - Kirim pesan kedua tanpa memuat ulang. Widget baru muncul setelah jawaban pertama dan kiriman kedua berhasil.
- Matikan JavaScript dan kirim. Halaman dimuat ulang dengan error pemeriksaan manusia.
- Kirim enam kali dalam satu menit setelah deploy. Kiriman keenam mendapat
429denganRetry-After: 60. - 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 |
|---|---|
Server-only module referenced by client | Sebuah komponen mengimpor sesuatu dari spam.server.ts yang direndernya. Hanya loader dan action yang boleh memakainya. |
| Percobaan kedua selalu gagal di siteverify | Widget masih memegang token yang sudah terpakai. Pastikan key-nya berubah setelah setiap jawaban fetcher. |
Invalid context value provided to handleRequest | Entry 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 JavaScript | Header action dibuang kecuali export headers mengembalikannya. Salin dari actionHeaders seperti yang ditunjukkan. |
Pertanyaan
Render widget di komponen route, baca cf-turnstile-response dari form data di action, lalu kirim bersama secret key Anda ke siteverify. Lanjutkan hanya jika jawabannya menyatakan success dengan action yang Anda atur di widget.
Deklarasikan binding rate limiting di wrangler.jsonc dan panggil limit dengan key dari header cf-connecting-ip di awal action. Kembalikan 429 dengan data() saat gagal.
Pada request dokumen, React Router hanya mengirim header yang dikembalikan export headers route Anda. Baca actionHeaders di sana dan salin yang Anda perlukan, seperti Retry-After.
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.