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.
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.
- Form action meminta alamat client dari request event.
- SvelteKit menyerahkan jawabannya ke adapter yang Anda pakai untuk deploy.
- adapter-node memakai remote address dari socket kecuali Anda mengatur ADDRESS_HEADER dan XFF_DEPTH.
- Adapter Vercel, Netlify, dan Cloudflare membaca alamat yang disediakan platformnya, jadi tidak ada yang perlu dikonfigurasi.
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
src/
env.ts
lib/
turnstile.ts
server/
spam.ts
routes/
contact/
+page.server.ts
+page.svelte
vite.config.ts
.envPUBLIC_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_tokenimport { 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 punyapublic: true, jadi hanya itu yang bisa diberikan$app/env/publicke browser. Apa pun di direktoriserver, sepertisrc/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.
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
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.
resetkembali dalam milidetik dan diubah menjadi detik penuh untuk menunggu. devdari$app/envmelewati pemeriksaan hostname secara lokal, karena token uji membawa hostname tiruan.- Siteverify menjawab
timeout-or-duplicateuntuk token yang lebih tua dari 300 detik atau sudah terpakai, itulah sebabnya halaman me-reset widget.
Form action
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. FormDataasli diteruskan, termasuk honeypot, tanpa token. Ikiform memberi skor pada honeypot yang terisi dan menyimpan pesannya sebagai spam.Originpengunjung diteruskan, karena fetch sisi server tidak mengirimnya dan Ikiform membacanya untuk mengenali uji gratis darilocalhost.
IKIFORM_SECRET_KEY di environment variable khusus server.Widget sebagai Svelte action
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
<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.resetTurnstileberjalan 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
.hpmenyembunyikan 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:
npm run build
ADDRESS_HEADER=X-Forwarded-For XFF_DEPTH=1 node buildKirim 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:
ADDRESS_HEADER=X-Forwarded-For
XFF_DEPTH=1Dokumentasi 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.
| Adapter | IP client | Secret |
|---|---|---|
adapter-node | ADDRESS_HEADER dan XFF_DEPTH | Environment proses |
adapter-vercel | Otomatis | Environment variable proyek |
adapter-netlify | Otomatis | Environment variable situs |
adapter-cloudflare | Otomatis | wrangler 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
- Jalankan
vite devdengan test key dan kirim pesan. Pesan muncul di Submissions. Workspace tanpa paket menerima 50 uji darilocalhostper bulan. - Kirim pesan kedua tanpa memuat ulang. Pesan berhasil, yang menunjukkan reset widget bekerja.
- Kirim enam pesan dalam satu menit. Pesan keenam menampilkan berapa detik harus menunggu.
- Di belakang proxy Anda, catat
getClientAddress()di log dari dua jaringan. Anda melihat dua alamat berbeda, bukan alamat proxy Anda. - 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 |
|---|---|
Semua pengunjung terkena rate limit bersama-sama di adapter-node | getClientAddress() mengembalikan alamat proxy Anda. Atur ADDRESS_HEADER dan XFF_DEPTH. |
| Kiriman kedua selalu gagal pemeriksaan manusia | Widget masih memegang token yang sudah terpakai. Panggil resetTurnstile di callback use:enhance setelah update. |
| Build gagal karena halaman dengan action di-prerender | Route 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 CSP | script-src atau frame-src tidak memuat https://challenges.cloudflare.com di csp.directives dalam vite.config.ts. |
Pertanyaan
Panggil getClientAddress() di request event dalam form action, load function, atau hook. Adapter yang menyediakannya. Di adapter-node di belakang proxy, atur ADDRESS_HEADER dan XFF_DEPTH, atau Anda akan mendapat alamat proxy.
use:enhance tidak memuat ulang halaman, jadi widget masih memegang token yang sudah dipakai. Reset widget di callback enhance setelah setiap jawaban.
Ya. Tambahkan https://challenges.cloudflare.com ke script-src dan frame-src di csp.directives, yang di SvelteKit 3 diberikan lewat opsi plugin sveltekit() di vite.config.ts. Dengan mode auto, SvelteKit menambahkan nonce atau hash untuk skripnya sendiri.
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.