Hentikan spam formulir kontak

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.

Preet SutharPendiri, Ikiform

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.

Memilih tempat pemeriksaan spam berjalan di situs Astrohalamankontaksesuaipermintaanadapterdanprerender= falseAstroActionlimit,siteverify,teruskanIkiformsecretkey,simpanformulirHTMLbiasatanpaadapter,tanpa JSIkiformhoneypot,Turnstile,limitkey Anda sendiriPOST formulirJSONtetap statisPOSTMemilih tempat pemeriksaan spam berjalan di situs Astrohalamankontaksesuai permintaanadapter dan prerender= falseAstro Actionlimit, siteverify,teruskanIkiformsecret key, simpanformulirHTMLbiasatanpaadapter,tanpa JSIkiformhoneypot,Turnstile,limitkey Anda sendiriPOST formulirJSONtetap statis
  1. Tentukan cara halaman kontak dirender.
  2. Dengan key Turnstile Anda sendiri, halaman dirender sesuai permintaan lewat adapter.
  3. Formulir mengirim ke Astro Action, yang membatasi per IP, memverifikasi token, dan meneruskan field-nya.
  4. Ikiform menyimpan pesan dan memberi skor pada honeypot.
  5. Agar situs tetap statis, formulir biasa mengirim langsung ke Ikiform, yang menjalankan honeypot, pemeriksaan Turnstile terkelola, dan rate limit.
Kedua jalur menghentikan bot yang sama. Jalur atas memberi Anda widget dan limit sendiri; jalur bawah sama sekali tidak mengirim kode server.

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

File
src/
  actions/
    index.ts
  lib/
    limit.ts
    turnstile.ts
  pages/
    contact.astro
astro.config.mjs
.env

Tambahkan 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:

.env
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_token
astro.config.mjs
import 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/server menolak dimuat di skrip client.
  • scriptDirective.resources menggantikan default Astro, jadi cantumkan 'self' lagi di samping Cloudflare. Astro tetap membuat hash untuk skripnya sendiri.
  • CSP tidak berjalan di astro dev. Periksa dengan astro build dan astro 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:

src/lib/limit.ts
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:

src/lib/limit.ts
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

src/lib/turnstile.ts
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 URLSearchParams tidak butuh header.
  • Hostname dibandingkan dengan context.url.hostname, host yang benar-benar dituju pengunjung. Token uji membawa hostname tiruan, jadi pemeriksaan ini dilewati di astro 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

src/actions/index.ts
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 _gotcha bersifat 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.
  • Origin pengunjung ikut diteruskan karena fetch dari sisi server tidak mengirimnya, dan Ikiform membacanya untuk mengenali uji gratis dari localhost.
  • TOO_MANY_REQUESTS dipetakan ke 429 dan FORBIDDEN ke 403.
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.

Halamannya

src/pages/contact.astro
---
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

HostingAdapterTempat secret disimpanLimit
Vercel@astrojs/vercelEnvironment variable proyekRedis
Netlify@astrojs/netlifyEnvironment variable situsRedis
Cloudflare@astrojs/cloudflarewrangler secret putBinding 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:

src/components/Contact.astro
---
---

<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

  1. Jalankan astro dev dengan test key dan kirim pesan. Pesan muncul di Submissions. Workspace tanpa paket menerima 50 uji dari localhost per bulan.
  2. Biarkan nama kosong dengan atribut required dihapus lewat devtools. Error field muncul di bawah input.
  3. Atur TURNSTILE_SECRET_KEY ke secret yang selalu gagal lalu kirim. Halaman menampilkan error pemeriksaan manusia.
  4. Kirim enam kali dalam satu menit. Kiriman keenam dijawab 429 dengan pesan limit.
  5. Kirim dengan honeypot terisi, _gotcha=bot. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook.
  6. Jalankan astro build dan astro preview, buka halamannya, dan pastikan console tidak menampilkan pelanggaran CSP.

Hal yang sering menjebak

MasalahYang harus dilakukan
clientAddress tidak tersedia di halaman yang di-prerenderHalaman atau action berjalan saat build. Tambahkan export const prerender = false ke halaman kontak.
Hasil action selalu undefinedFormulir mengirim ke halaman yang di-prerender. Form action hanya berjalan di halaman yang dirender sesuai permintaan.
Widget kosong di production tetapi normal di devCSP memblokirnya. scriptDirective.resources harus mencantumkan https://challenges.cloudflare.com, dan frame-src juga harus mengizinkannya.
403 tanpa pesan di setiap kiriman dari custom domainsecurity.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 datangKiriman 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

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