문의 폼 스팸 막기6분 분량
Turnstile을 이용한 SvelteKit 폼 스팸 방지
getClientAddress를 쓰는 폼 액션, IP별 제한, use:enhance 뒤의 Turnstile, 이를 허용하는 CSP로 SvelteKit 폼을 스팸으로부터 보호하세요.
폼 액션에서 검사하세요. getClientAddress()로 제한하고, siteverify로 Turnstile 토큰을 검증한 뒤, 허니팟과 함께 필드를 전달합니다. 위젯은 Svelte 액션으로 렌더링하고, 토큰은 한 번만 쓸 수 있으므로 use:enhance 콜백에서 초기화하세요. sveltekit() 플러그인의 csp 옵션에서 challenges.cloudflare.com을 허용하고, adapter-node에서는 실제 IP가 담긴 프록시 헤더를 SvelteKit에 알려 주세요.
SvelteKit에서 다른 점
SvelteKit은 요청 이벤트의 getClientAddress()로 방문자의 IP를 바로 줍니다. 이 값의 출처는 어댑터에 따라 다르며, adapter-node의 기본값은 프록시 뒤에서는 틀립니다. 이것만 바로잡으면 나머지 검사는 폼 액션에 깔끔하게 들어갑니다.
- 폼 액션이 요청 이벤트에 클라이언트 주소를 묻습니다.
- SvelteKit은 배포에 쓰는 어댑터에 답을 맡깁니다.
- adapter-node는 ADDRESS_HEADER와 XFF_DEPTH를 설정하지 않으면 소켓의 원격 주소를 씁니다.
- Vercel, Netlify, Cloudflare 어댑터는 플랫폼이 제공하는 주소를 읽으므로 설정할 것이 없습니다.
SvelteKit의 또 다른 세부 사항은 use:enhance입니다. 페이지를 새로 고치지 않고 제출하므로 사람에게는 더 좋지만, Turnstile이 스스로 다시 렌더링되지 않는다는 뜻이기도 합니다. 응답마다 위젯을 직접 초기화해야 합니다.
프로젝트 설정
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은
src/env.ts에 선언한 변수를 읽습니다.public: true가 있는 것은 site key뿐이므로,$app/env/public이 브라우저에 넘길 수 있는 것도 site key뿐입니다.src/lib/server처럼server디렉터리에 있는 것은 클라이언트 코드에서 아예 import할 수 없으며, 시도하면 SvelteKit이 빌드를 실패시킵니다. - 선언한 변수는
static: true를 설정하지 않으면 앱이 시작될 때 읽히므로, 키를 교체하면 다시 빌드할 필요 없이 재시작만 하면 됩니다.$env/*모듈은 여전히 작동하지만 SvelteKit 3에서는 지원 중단되었습니다. - Cloudflare의 테스트 키는
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은 옵션을 vite.config.ts에서 받아 sveltekit() 플러그인에 넘기며, svelte.config.js는 더 이상 읽지 않습니다. mode: "auto"를 쓰면 SvelteKit은 서버 렌더링 페이지의 자체 인라인 스크립트에는 nonce를, 프리렌더링 페이지에는 해시를 추가하므로 정책에 'unsafe-inline'이 필요 없습니다. Turnstile은 script-src가 허용하는 호스트에서 불러오며, frame-src가 허용하는 iframe을 그립니다.
서버 전용 헬퍼
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;
}
}- 제한은 모든 서버 인스턴스가 공유하는 Redis의 슬라이딩 구간으로, IP당 분당 메시지 5건입니다.
reset은 밀리초로 돌아오며, 기다려야 할 정수 초로 바뀝니다. - 테스트 토큰에는 더미 호스트 이름이 있으므로,
$app/env의dev로 로컬에서는 호스트 이름 검사를 건너뜁니다. - siteverify는 300초보다 오래되었거나 이미 사용된 토큰에
timeout-or-duplicate로 응답하며, 그래서 페이지가 위젯을 초기화합니다.
폼 액션
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;- 제한은 본문을 파싱하기 전에 실행됩니다.
fail(429)는 메시지와 함께 방문자를 페이지에 머물게 합니다. - 그 밖의 모든 실패는
values를 반환하므로 오류 후에 입력이 다시 채워집니다. - 원래의
FormData가 토큰만 빠지고 허니팟을 포함해 전달됩니다. Ikiform은 채워진 허니팟에 점수를 매기고 메시지를 스팸으로 저장합니다. - 서버 측 fetch는
Origin을 보내지 않으므로 방문자의 이 헤더를 넘기며, Ikiform은 이를 읽어 무료localhost테스트를 알아봅니다.
IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.Svelte 액션으로 만든 위젯
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 액션은 서드파티 위젯에 딱 맞는 자리입니다. 요소가 마운트될 때 실행되고, 요소가 사라질 때 destroy가 실행됩니다. 위젯 ID는 요소에 저장되므로 페이지가 정확히 그 위젯을 초기화할 수 있습니다.
페이지
<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 })는 결과를 적용하면서 방문자가 입력한 내용을 유지합니다. 기본값은 브라우저처럼 폼을 지웁니다.resetTurnstile은 성공이든 아니든 응답마다 실행됩니다. 사용한 토큰은 사라지고 새 토큰이 준비됩니다.- JavaScript가 없어도 폼은 POST되고 액션도 실행되지만, 위젯이 렌더링되지 않으므로 siteverify가 명확한 메시지와 함께 거부합니다.
- 폼 액션이 있는 페이지는 프리렌더링할 수 없으므로 이 라우트는 항상 서버에서 렌더링됩니다. 다른 페이지는 프리렌더링된 채로 둘 수 있습니다.
- 허니팟 가이드처럼
.hp클래스가 허니팟을 화면 밖으로 숨깁니다.
로컬에서 테스트하기
vite dev에서는 앱 앞에 어댑터가 없으므로 getClientAddress()가 루프백 주소를 반환하고 모든 로컬 탭이 하나의 버킷을 공유합니다. 메시지를 확인하는 데는 괜찮지만 프록시 설정을 확인하는 데는 맞지 않습니다. 그것을 테스트하려면 adapter-node로 빌드하고 프로덕션에서 쓸 것과 같은 변수로 결과물을 실행하세요.
npm run build
ADDRESS_HEADER=X-Forwarded-For XFF_DEPTH=1 node buildX-Forwarded-For 값을 바꿔 가며 몇 번 POST하세요. XFF_DEPTH=1이면 SvelteKit은 가장 오른쪽 주소, 즉 프록시가 추가할 주소를 읽으므로, 클라이언트가 왼쪽에 지어낸 값은 아무것도 바꾸지 못합니다.
배포
프록시 하나 뒤에서 adapter-node로 직접 운영하는 서버라면 실제 IP가 어디 있는지 SvelteKit에 알려 주세요.
ADDRESS_HEADER=X-Forwarded-For
XFF_DEPTH=1SvelteKit 문서는 헤더가 위조하기 쉬우며 가장 왼쪽 X-Forwarded-For 주소를 읽는 것이 전형적인 실수라고 경고합니다. XFF_DEPTH는 신뢰하는 프록시를 오른쪽부터 세므로, 운영하는 프록시 수로 설정하고 프록시를 우회해 앱에 접근할 수 없게 하세요. SvelteKit 3에서는 adapter-node의 ORIGIN 변수가 없어졌습니다. 프록시가 원래 호스트를 넘기지 않는다면 sveltekit() 옵션에 paths.origin을 설정해 SvelteKit 자체 폼 검사가 공개 URL을 알게 하세요.
| 어댑터 | 클라이언트 IP | 시크릿 |
|---|---|---|
adapter-node | ADDRESS_HEADER와 XFF_DEPTH | 프로세스 환경 변수 |
adapter-vercel | 자동 | 프로젝트 환경 변수 |
adapter-netlify | 자동 | 사이트 환경 변수 |
adapter-cloudflare | 자동 | wrangler secret put |
Ikiform에 검사 맡기기
이 중 아무것도 직접 운영하지 않으려면 +page.server.ts를 지우고 폼의 action을 use:enhance 없이 Ikiform 엔드포인트로 지정하세요. 그러면 페이지를 정적 사이트의 나머지처럼 프리렌더링할 수 있습니다.
폼의 Spam check를 Ikiform으로 설정하면 토큰 없는 POST에 ikiform.com의 짧은 확인 페이지가 표시되어 Turnstile을 실행하고 필드를 이어서 보냅니다. Rate limit은 방문자당 10분에 2건인 Strict를 고르고, Allowed domains에 도메인을 등록하세요. Ikiform은 걸러 낸 스팸을 30일 동안 보관하므로 오탐은 클릭 한 번으로 복원할 수 있습니다. 엔드포인트와 키는 SvelteKit 설정 가이드에서 다룹니다.
작동 확인하기
- 테스트 키로
vite dev를 실행하고 메시지를 보내세요. Submissions에 나타납니다. 플랜이 없는 워크스페이스는 매월localhost테스트 50건을 받습니다. - 새로 고치지 않고 두 번째 메시지를 보내세요. 성공한다면 위젯 초기화가 작동한 것입니다.
- 1분 안에 메시지 여섯 개를 보내세요. 여섯 번째에 몇 초 기다려야 하는지 표시됩니다.
- 프록시 뒤에서 두 네트워크로부터
getClientAddress()를 로그로 남기세요. 프록시의 주소가 아니라 서로 다른 두 주소가 보입니다. - 허니팟을 채워
_gotcha=bot으로 POST하세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다.
주의할 점
| 문제 | 해결 방법 |
|---|---|
adapter-node에서 모든 방문자가 함께 레이트 리밋에 걸림 | getClientAddress()가 프록시의 주소를 반환합니다. ADDRESS_HEADER와 XFF_DEPTH를 설정하세요. |
| 두 번째 제출이 항상 사람 확인에 실패함 | 위젯이 이미 쓴 토큰을 유지하고 있습니다. use:enhance 콜백에서 update 다음에 resetTurnstile을 호출하세요. |
| 액션이 있는 페이지가 프리렌더링되어 빌드가 실패함 | 문의 라우트가 레이아웃에서 prerender = true를 물려받았습니다. 액션이 있는 페이지는 프리렌더링할 수 없으므로, 해당 +page.server.ts에 export const prerender = false를 설정하세요. |
| 위젯 영역이 비어 있고 콘솔에 CSP 위반이 표시됨 | vite.config.ts의 csp.directives에서 script-src나 frame-src에 https://challenges.cloudflare.com이 빠져 있습니다. |
질문
폼 액션, load 함수, 훅에서 요청 이벤트의 getClientAddress()를 호출하세요. 어댑터가 값을 제공합니다. 프록시 뒤의 adapter-node에서는 ADDRESS_HEADER와 XFF_DEPTH를 설정하지 않으면 프록시의 주소를 받습니다.
use:enhance는 페이지를 새로 로드하지 않으므로 위젯이 이미 쓴 토큰을 유지합니다. 응답마다 enhance 콜백에서 위젯을 초기화하세요.
네. csp.directives의 script-src와 frame-src에 https://challenges.cloudflare.com을 추가하세요. SvelteKit 3에서는 vite.config.ts의 sveltekit() 플러그인 옵션에서 받습니다. mode를 auto로 하면 SvelteKit이 자체 스크립트에 nonce나 해시를 추가합니다.
아니요, 무료 플랜은 없습니다. 테스트는 무료이며, 결제하기 전에 localhost에서 매월 50건의 테스트 제출을 보낼 수 있습니다. 실제로 운영하려면 제출 500건에 월 $6부터인 Pro를 쓰거나, 제출 10,000건에 $49 또는 50,000건에 $149로 한 번만 결제할 수 있으며 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.