Hentikan spam formulir kontak6 menit baca
Perlindungan spam formulir Astro dengan Turnstile
Lindungi formulir Astro dari spam dengan Action: honeypot, Turnstile yang dicek dengan secret astro:env, limit per IP, dan CSP bawaan Astro.
Taruh pemeriksaan di Astro Action yang menjadi tujuan kiriman formulir tanpa JavaScript: batasi berdasarkan context.clientAddress, verifikasi token Turnstile dengan secret dari astro:env/server, lalu teruskan field-nya bersama honeypot. Halaman kontak harus dirender sesuai permintaan, jadi tambahkan adapter dan prerender = false di sana. Jika Anda lebih suka seluruh situs tetap statis, kirim formulir biasa ke Ikiform dan biarkan Ikiform menjalankan pemeriksaan yang sama.
Apa yang berbeda di Astro
Astro membangun HTML statis secara default, dan HTML statis tidak bisa menyimpan rahasia. Secret Turnstile, secret key Ikiform, dan penghitung rate limit semuanya butuh kode yang berjalan per request, jadi keputusan pertama di situs Astro adalah apakah halaman kontak dirender sesuai permintaan atau tidak.
- Tentukan cara halaman kontak dirender.
- Dengan key Turnstile Anda sendiri, halaman dirender sesuai permintaan lewat adapter.
- Formulir mengirim ke Astro Action, yang membatasi per IP, memverifikasi token, dan meneruskan field-nya.
- Ikiform menyimpan pesan dan memberi skor pada honeypot.
- Agar situs tetap statis, formulir biasa mengirim langsung ke Ikiform, yang menjalankan honeypot, pemeriksaan Turnstile terkelola, dan rate limit.
Astro juga memeriksa origin kiriman formulir ke halaman on-demand secara default, lewat security.checkOrigin. Itu mencegah halaman situs lain mengirim ke situs Anda. Tetapi tidak menghentikan skrip, yang bisa mengirim header apa saja, jadi pemeriksaan di bawah tetap berlaku.
Setup proyek
src/
actions/
index.ts
lib/
limit.ts
turnstile.ts
pages/
contact.astro
astro.config.mjs
.envTambahkan adapter dengan npx astro add vercel, netlify, atau cloudflare. Deklarasikan setiap variabel di skema astro:env, agar secret yang hilang gagal saat import dan secret tidak pernah bisa diimpor ke kode client:
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_tokenimport vercel from "@astrojs/vercel";
import { defineConfig, envField } from "astro/config";
export default defineConfig({
adapter: vercel(),
env: {
schema: {
PUBLIC_TURNSTILE_SITE_KEY: envField.string({ context: "client", access: "public" }),
TURNSTILE_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
UPSTASH_REDIS_REST_URL: envField.string({ context: "server", access: "secret" }),
UPSTASH_REDIS_REST_TOKEN: envField.string({ context: "server", access: "secret" }),
},
},
security: {
csp: {
scriptDirective: {
resources: ["'self'", "https://challenges.cloudflare.com"],
},
directives: ["frame-src https://challenges.cloudflare.com"],
},
},
});access: "secret"menjaga nilai tetap di luar bundle.astro:env/servermenolak dimuat di skrip client.scriptDirective.resourcesmenggantikan default Astro, jadi cantumkan'self'lagi di samping Cloudflare. Astro tetap membuat hash untuk skripnya sendiri.- CSP tidak berjalan di
astro dev. Periksa denganastro builddanastro preview.
Limit per IP
Di dalam Action, context.clientAddress adalah IP pengunjung sebagaimana dilaporkan adapter dari platform, jadi Anda tidak pernah mem-parsing X-Forwarded-For sendiri. Nilai ini hanya ada di route on-demand dan melempar error di route yang di-prerender. Penghitung ini menyimpan satu key Redis per IP per menit, jadi bekerja di seluruh instance serverless:
import { Redis } from "@upstash/redis";
import { UPSTASH_REDIS_REST_TOKEN, UPSTASH_REDIS_REST_URL } from "astro:env/server";
const redis = new Redis({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN });
export async function overLimit(ip: string, limit = 5, windowSeconds = 60) {
const key = `contact:${ip}:${Math.floor(Date.now() / 1000 / windowSeconds)}`;
const [count] = await redis.multi().incr(key).expire(key, windowSeconds).exec<[number, number]>();
return count > limit;
}Di Cloudflare, Astro 7 membaca binding dari cloudflare:workers, bukan dari Astro.locals.runtime. Binding rate limiting Workers yang dideklarasikan di wrangler.jsonc menggantikan Redis tanpa panggilan jaringan, dengan konsekuensi hitungan yang bersifat perkiraan per lokasi:
import { env } from "cloudflare:workers";
export async function overLimit(ip: string) {
const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
return !success;
}Verifikasi token
import { TURNSTILE_SECRET_KEY } from "astro:env/server";
export async function isHuman(token: string, ip: string, host: string) {
const body = new URLSearchParams({ secret: TURNSTILE_SECRET_KEY, 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 };
if (!verdict.success || verdict.action !== "contact") return false;
return import.meta.env.DEV || verdict.hostname === host;
} catch {
return false;
}
}- Siteverify menerima body form-encoded, jadi
URLSearchParamstidak butuh header. - Hostname dibandingkan dengan
context.url.hostname, host yang benar-benar dituju pengunjung. Token uji membawa hostname tiruan, jadi pemeriksaan ini dilewati diastro dev. - Token berlaku selama 300 detik dan hanya bisa dipakai sekali. Kiriman formulir biasa memuat ulang halaman, dan Turnstile menerbitkan token baru setiap kali.
Action
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";
import { overLimit } from "../lib/limit";
import { isHuman } from "../lib/turnstile";
export const server = {
sendMessage: defineAction({
accept: "form",
input: z.object({
name: z.string().trim().min(1, "Tell us your name.").max(120),
email: z.email("Enter an address we can reply to."),
message: z.string().trim().min(10, "A few words more, please.").max(5000),
_gotcha: z.string().optional(),
"cf-turnstile-response": z.string().max(2048).optional(),
}),
handler: async (input, context) => {
const ip = context.clientAddress;
if (await overLimit(ip)) {
throw new ActionError({ code: "TOO_MANY_REQUESTS", message: "Too many messages. Try again in a minute." });
}
if (!(await isHuman(input["cf-turnstile-response"] ?? "", ip, context.url.hostname))) {
throw new ActionError({ code: "FORBIDDEN", message: "We couldn't confirm you're human. Try again." });
}
const { "cf-turnstile-response": _token, ...fields } = input;
const origin = context.request.headers.get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(fields),
});
if (!response.ok) throw new ActionError({ code: "BAD_GATEWAY", message: "Your message didn't send. Try again." });
return { sent: true };
},
}),
};- Zod berjalan sebelum handler, jadi kiriman yang rusak tidak memakan panggilan Redis. Error field kembali ke halaman lewat
isInputError. - Honeypot
_gotchabersifat opsional di skema dan diteruskan tanpa diubah. Ikiform memberi skor spam pada honeypot yang terisi, jadi Anda tetap bisa meninjaunya. - Token dihapus sebelum diteruskan. Ikiform tidak butuh token yang sudah Anda verifikasi.
Originpengunjung ikut diteruskan karena fetch dari sisi server tidak mengirimnya, dan Ikiform membacanya untuk mengenali uji gratis darilocalhost.TOO_MANY_REQUESTSdipetakan ke429danFORBIDDENke403.
IKIFORM_SECRET_KEY di environment variable khusus server.Halamannya
---
import { actions, isInputError } from "astro:actions";
import { PUBLIC_TURNSTILE_SITE_KEY } from "astro:env/client";
export const prerender = false;
const result = Astro.getActionResult(actions.sendMessage);
const fields = result?.error && isInputError(result.error) ? result.error.fields : {};
---
<main>
<h1>Contact us</h1>
{result?.data?.sent ? (
<p role="status">Thanks. We'll reply by email.</p>
) : (
<form method="POST" action={actions.sendMessage}>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required maxlength="120" />
{fields.name && <p class="field-error">{fields.name[0]}</p>}
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
{fields.email && <p class="field-error">{fields.email[0]}</p>}
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required minlength="10"></textarea>
{fields.message && <p class="field-error">{fields.message[0]}</p>}
<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 class="cf-turnstile" data-sitekey={PUBLIC_TURNSTILE_SITE_KEY} data-action="contact"></div>
{result?.error && !isInputError(result.error) && <p role="alert">{result.error.message}</p>}
<button>Send message</button>
</form>
)}
</main>
<script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<style>
.hp {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}
</style>Formulir berfungsi tanpa JavaScript client sama sekali. action={actions.sendMessage} mengirim ke halaman yang sama dengan nama action di query string; Astro menjalankan action dan Astro.getActionResult() membaca hasilnya saat merender. Hanya halaman ini yang dirender sesuai permintaan. Sisa situs tetap statis.
- Me-refresh setelah kiriman gagal akan meminta untuk mengirim ulang formulir. Dokumentasi Astro menyarankan pola POST, redirect, GET jika itu mengganggu Anda, dengan membawa hasilnya di session.
- Skrip Turnstile memakai
is:inline, jadi Astro tidak mengubahnya. Skrip harus dimuat dari URL milik Cloudflare sendiri. - Memanggil action dari skrip client di halaman statis juga bisa, tetapi Anda harus memanggil
turnstile.reset()sendiri setelah setiap percobaan.
Deploy
| Hosting | Adapter | Tempat secret disimpan | Limit |
|---|---|---|---|
| Vercel | @astrojs/vercel | Environment variable proyek | Redis |
| Netlify | @astrojs/netlify | Environment variable situs | Redis |
| Cloudflare | @astrojs/cloudflare | wrangler secret put | Binding rate limiting Workers |
Tambahkan hostname production Anda ke widget Turnstile sebelum deploy, dan tambahkan juga hostname preview jika Anda menguji di sana.
Biarkan tetap statis dan serahkan pemeriksaan ke Ikiform
Kebanyakan situs Astro bersifat statis, dan menambahkan adapter hanya untuk formulir kontak adalah biaya nyata. Tanpa adapter, kirim formulir langsung ke endpoint Ikiform Anda:
---
---
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required minlength="10"></textarea>
<div class="hp" aria-hidden="true">
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
</div>
<button>Send message</button>
</form>Atur Spam check formulir ke Ikiform. Kiriman tanpa token mendapat halaman pemeriksaan singkat di ikiform.com yang menjalankan Turnstile dan meneruskan field-nya, jadi halaman tidak butuh skrip, key, atau perubahan CSP. Pilih Rate limit Strict, 2 per 10 menit per pengunjung, dan tambahkan domain Anda di Allowed domains. Spam mendapat jawaban yang sama dengan pesan asli dan disimpan selama 30 hari agar Anda bisa menyelamatkan false positive. Panduan setup Astro menunjukkan sisanya.
Pastikan berfungsi
- Jalankan
astro devdengan test key dan kirim pesan. Pesan muncul di Submissions. Workspace tanpa paket menerima 50 uji darilocalhostper bulan. - Biarkan nama kosong dengan atribut
requireddihapus lewat devtools. Error field muncul di bawah input. - Atur
TURNSTILE_SECRET_KEYke secret yang selalu gagal lalu kirim. Halaman menampilkan error pemeriksaan manusia. - Kirim enam kali dalam satu menit. Kiriman keenam dijawab
429dengan pesan limit. - Kirim dengan honeypot terisi,
_gotcha=bot. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook. - Jalankan
astro builddanastro preview, buka halamannya, dan pastikan console tidak menampilkan pelanggaran CSP.
Hal yang sering menjebak
| Masalah | Yang harus dilakukan |
|---|---|
clientAddress tidak tersedia di halaman yang di-prerender | Halaman atau action berjalan saat build. Tambahkan export const prerender = false ke halaman kontak. |
| Hasil action selalu undefined | Formulir mengirim ke halaman yang di-prerender. Form action hanya berjalan di halaman yang dirender sesuai permintaan. |
| Widget kosong di production tetapi normal di dev | CSP memblokirnya. scriptDirective.resources harus mencantumkan https://challenges.cloudflare.com, dan frame-src juga harus mengizinkannya. |
403 tanpa pesan di setiap kiriman dari custom domain | security.checkOrigin melihat origin yang berbeda, sering kali karena proxy menulis ulang host. Pastikan header host yang sampai ke Astro cocok dengan domain halaman. |
| Kiriman ada di dashboard tetapi tidak ada email atau webhook yang datang | Kiriman itu disimpan sebagai spam. Spam tidak mengirim apa pun. Buka di filter Spam untuk melihat alasannya, dan tandai sebagai bukan spam untuk mengirim email dan webhook-nya. |
Pertanyaan
Tidak sebagai form action. Formulir yang mengirim ke action butuh halaman yang dirender sesuai permintaan lewat adapter. Di situs yang sepenuhnya statis, kirim formulir ke form backend seperti Ikiform sebagai gantinya.
Gunakan context.clientAddress di Action atau Astro.clientAddress di halaman. Adapter mengisinya dari platform. Nilai ini hanya ada di route yang dirender sesuai permintaan.
Ya, dengan security.csp, yang stabil sejak Astro 6. Tambahkan https://challenges.cloudflare.com ke scriptDirective.resources di samping 'self', dan tambahkan direktif frame-src untuk host yang sama. Uji dengan build production, karena CSP tidak berjalan di dev.
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.