문의 폼 스팸 막기

문의 폼 스팸 막기5분 분량

Turnstile을 이용한 Astro 폼 스팸 방지

허니팟, astro:env 시크릿으로 확인하는 Turnstile, IP별 제한, Astro 내장 CSP를 갖춘 Action으로 Astro 폼을 스팸으로부터 보호하세요.

Preet SutharIkiform 창업자

폼이 JavaScript 없이 POST하는 Astro Action에 검사를 두세요. context.clientAddress로 제한하고, astro:env/server의 시크릿으로 Turnstile 토큰을 검증한 뒤, 허니팟과 함께 필드를 전달합니다. 문의 페이지는 온디맨드로 렌더링되어야 하므로 어댑터를 추가하고 해당 페이지에 prerender = false를 지정하세요. 사이트 전체를 정적으로 유지하고 싶다면 일반 폼을 Ikiform으로 POST하고 같은 검사를 Ikiform에 맡기세요.

Astro에서 다른 점

Astro는 기본적으로 정적 HTML을 빌드하며, 정적 HTML은 시크릿을 보관할 수 없습니다. Turnstile 시크릿, Ikiform 시크릿 키, 레이트 리밋 카운터는 모두 요청마다 실행되는 코드가 필요하므로, Astro 사이트에서 첫 번째 결정은 문의 페이지를 온디맨드로 렌더링할지 여부입니다.

Astro 사이트에서 스팸 검사를 어디서 실행할지 고르기문의 페이지온디맨드어댑터 +prerender= falseAstroAction제한,siteverify,전달Ikiform시크릿 키, 저장일반HTML 폼어댑터 없음,JavaScript없음Ikiform허니팟,Turnstile,제한자체 키폼 POSTJSON정적으로 유지POSTAstro 사이트에서 스팸 검사를 어디서 실행할지 고르기문의 페이지온디맨드어댑터 + prerender =falseAstro Action제한, siteverify, 전달Ikiform시크릿 키, 저장일반 HTML 폼어댑터 없음,JavaScript없음Ikiform허니팟,Turnstile,제한자체 키폼 POSTJSON정적으로 유지
  1. 문의 페이지를 어떻게 렌더링할지 정합니다.
  2. 자체 Turnstile 키를 쓰면 페이지는 어댑터를 통해 온디맨드로 렌더링됩니다.
  3. 폼이 Astro Action으로 POST하고, Action이 IP별로 제한하고 토큰을 검증한 뒤 필드를 전달합니다.
  4. Ikiform이 메시지를 저장하고 허니팟 점수를 매깁니다.
  5. 사이트를 정적으로 유지하려면 일반 폼이 Ikiform으로 바로 POST하고, Ikiform이 허니팟, 관리형 Turnstile 검사, 레이트 리밋을 실행합니다.
두 방식 모두 같은 봇을 막습니다. 위쪽은 자체 위젯과 자체 제한을 주고, 아래쪽은 서버 코드를 전혀 배포하지 않습니다.

Astro는 기본적으로 security.checkOrigin을 통해 온디맨드 페이지로 오는 폼 POST의 출처도 확인합니다. 이 검사는 다른 사이트의 페이지가 여러분의 페이지로 POST하는 것을 막습니다. 원하는 헤더를 무엇이든 보낼 수 있는 스크립트는 막지 못하므로, 아래 검사는 여전히 필요합니다.

프로젝트 설정

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

npx astro add vercel, netlify, cloudflare로 어댑터를 추가하세요. 모든 변수를 astro:env 스키마에 선언하면 시크릿이 없을 때 import 시점에 실패하고, 시크릿이 클라이언트 코드로 import되는 일이 없습니다.

.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"은 값이 번들에 들어가지 않게 합니다. astro:env/server는 클라이언트 스크립트에서 로드되지 않습니다.
  • scriptDirective.resources는 Astro의 기본값을 대체하므로, Cloudflare 옆에 'self'를 다시 적으세요. Astro는 여전히 자체 스크립트를 해시합니다.
  • CSP는 astro dev에서 실행되지 않습니다. astro build와 astro preview로 확인하세요.

IP별 제한

Action에서 context.clientAddress는 어댑터가 플랫폼에서 받아 알려 주는 방문자의 IP이므로, X-Forwarded-For를 직접 파싱할 필요가 없습니다. 온디맨드 라우트에만 있으며, 프리렌더링된 라우트에서는 예외를 던집니다. 이 카운터는 IP별로 분당 Redis 키 하나를 유지하므로 서버리스 인스턴스 사이에서도 작동합니다.

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;
}

Cloudflare에서 Astro 7은 Astro.locals.runtime 대신 cloudflare:workers에서 바인딩을 읽습니다. wrangler.jsonc에 선언한 Workers 레이트 리밋 바인딩은 네트워크 호출 없이 Redis를 대체하지만, 위치별 근사치 집계라는 대가가 있습니다.

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;
}

토큰 검증하기

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는 form-encoded 본문을 받으므로 URLSearchParams에는 헤더가 필요 없습니다.
  • 호스트 이름은 방문자가 실제로 POST한 호스트인 context.url.hostname과 비교합니다. 테스트 토큰에는 더미 호스트 이름이 들어 있으므로 astro dev에서는 이 검사를 건너뜁니다.
  • 토큰은 300초 동안 유효하며 한 번만 쓸 수 있습니다. 일반 폼 POST는 페이지를 새로 로드하고, Turnstile은 그때마다 새 토큰을 발급합니다.

액션

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가 핸들러보다 먼저 실행되므로, 형식이 잘못된 POST는 Redis 호출을 쓰지 않습니다. 필드 오류는 isInputError를 통해 페이지로 돌아옵니다.
  • 허니팟 _gotcha는 스키마에서 선택 항목이며 그대로 전달됩니다. Ikiform은 채워진 허니팟을 스팸으로 점수 매기므로 여전히 검토할 수 있습니다.
  • 토큰은 전달하기 전에 제거합니다. 이미 검증한 토큰은 Ikiform에 쓸모가 없습니다.
  • 서버 측 fetch는 Origin을 보내지 않으므로 방문자의 이 헤더를 함께 보내며, Ikiform은 이를 읽어 무료 localhost 테스트를 알아봅니다.
  • TOO_MANY_REQUESTS는 429에, FORBIDDEN은 403에 대응합니다.
서버 측 버전은 시크릿 키로 POST하므로 Ikiform은 Allowed domains와 자체 사람 확인을 건너뜁니다. 그러면 모든 POST가 서버의 IP에서 오고, 방문자당 분당 5건인 Standard 제한이 모든 방문자를 합쳐서 셉니다. 서버를 거쳐 POST하는 폼에는 Relaxed 또는 Custom 레이트 리밋을 고르고, IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.

페이지

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>

폼은 클라이언트 JavaScript 없이 작동합니다. action={actions.sendMessage}는 쿼리 문자열에 액션 이름을 담아 같은 페이지로 POST하고, Astro가 액션을 실행하면 렌더링 중에 Astro.getActionResult()가 결과를 읽습니다. 이 페이지만 온디맨드로 렌더링되고, 사이트의 나머지는 정적으로 유지됩니다.

  • POST가 실패한 뒤 새로 고치면 폼을 다시 보낼지 묻습니다. 이것이 거슬린다면 Astro 문서는 결과를 세션에 담아 POST, 리디렉션, GET 패턴을 쓰라고 권합니다.
  • Turnstile 스크립트는 is:inline을 쓰므로 Astro가 건드리지 않습니다. Cloudflare의 공식 URL에서 불러와야 합니다.
  • 정적 페이지의 클라이언트 스크립트에서 액션을 호출해도 작동하지만, 그 경우 시도할 때마다 직접 turnstile.reset()을 호출해야 합니다.

배포

호스트어댑터시크릿 보관 위치제한
Vercel@astrojs/vercel프로젝트 환경 변수Redis
Netlify@astrojs/netlify사이트 환경 변수Redis
Cloudflare@astrojs/cloudflarewrangler secret putWorkers 레이트 리밋 바인딩

배포하기 전에 Turnstile 위젯에 프로덕션 호스트 이름을 추가하고, 미리보기에서 테스트한다면 미리보기 호스트 이름도 추가하세요.

정적으로 유지하고 Ikiform에 검사 맡기기

대부분의 Astro 사이트는 정적이며, 문의 폼 하나 때문에 어댑터를 추가하는 것은 실제 비용입니다. 어댑터 없이 폼을 Ikiform 엔드포인트로 바로 POST하세요.

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>

폼의 Spam check를 Ikiform으로 설정하세요. 토큰 없는 POST에는 ikiform.com의 짧은 확인 페이지가 표시되어 Turnstile을 실행하고 필드를 이어서 보내므로, 페이지에는 스크립트도, 키도, CSP 변경도 필요 없습니다. Rate limit은 방문자당 10분에 2건인 Strict를 고르고, Allowed domains에 도메인을 추가하세요. 스팸은 실제 메시지와 같은 응답을 받고 30일 동안 보관되므로 오탐을 되살릴 수 있습니다. 나머지는 Astro 설정 가이드에 있습니다.

작동 확인하기

  1. 테스트 키로 astro dev를 실행하고 메시지를 보내세요. Submissions에 나타납니다. 플랜이 없는 워크스페이스는 매월 localhost 테스트 50건을 받습니다.
  2. 개발자 도구에서 required 속성을 지우고 이름을 비워 두세요. 입력 아래에 필드 오류가 표시됩니다.
  3. TURNSTILE_SECRET_KEY를 항상 실패하는 시크릿으로 설정하고 제출하세요. 페이지에 사람 확인 오류가 표시됩니다.
  4. 1분 안에 여섯 번 제출하세요. 여섯 번째는 제한 메시지와 함께 429로 응답합니다.
  5. 허니팟을 채워 _gotcha=bot으로 POST하세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다.
  6. astro build와 astro preview를 실행하고 페이지를 열어 콘솔에 CSP 위반이 없는지 확인하세요.

주의할 점

문제해결 방법
프리렌더링된 페이지에서 clientAddress를 쓸 수 없음페이지나 액션이 빌드 시점에 실행되었습니다. 문의 페이지에 export const prerender = false를 추가하세요.
액션 결과가 항상 undefined임폼이 프리렌더링된 페이지로 POST했습니다. 폼 액션은 온디맨드로 렌더링되는 페이지에서만 실행됩니다.
개발 환경에서는 괜찮은데 프로덕션에서 위젯이 비어 있음CSP가 위젯을 막고 있습니다. scriptDirective.resources에 https://challenges.cloudflare.com이 있어야 하고, frame-src도 이를 허용해야 합니다.
커스텀 도메인에서 보낸 모든 POST가 메시지 없이 403security.checkOrigin이 다른 출처를 보았습니다. 흔히 프록시가 호스트를 바꿔 쓴 경우입니다. Astro에 도달하는 host 헤더가 페이지의 도메인과 일치하는지 확인하세요.
제출은 대시보드에 있는데 이메일이나 웹훅이 오지 않음스팸으로 저장되었습니다. 스팸은 아무것도 보내지 않습니다. Spam 필터에서 열어 사유를 확인하고, 스팸 아님으로 표시하면 이메일과 웹훅이 발송됩니다.

질문

막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.