문의 폼 스팸 막기

문의 폼 스팸 막기4분 분량

폼 제출에 레이트 리밋을 거는 방법

공유 저장소로 방문자별 폼 제출에 레이트 리밋을 걸고, Retry-After와 함께 429로 응답하며, 하나의 IP를 쓰는 사무실이 막히지 않게 하세요.

Preet SutharIkiform 창업자

Redis처럼 모든 서버 인스턴스가 공유하는 저장소에서 방문자 IP별 POST 수를 세고, 짧은 버스트를 허용하며, 횟수가 다 떨어지면 Retry-After 헤더와 함께 429로 응답하세요. 메모리 내 카운터는 오래 실행되는 서버 하나에서만 작동하며, 서버리스 호스트에서는 절대 작동하지 않습니다. 제한은 실제 사람들이 폼을 쓰는 방식에 맞춰 정하고, 많은 사람이 하나의 IP를 공유하는 사무실과 행사장을 위한 여유를 두세요.

레이트 리밋이 다른 점

레이트 리밋은 스팸과 정상 메일을 구분하지 못합니다. 막는 것은 양입니다. 반복해서 POST하는 스크립트, 폼을 계속 돌리는 봇, 버튼을 연타하는 사람입니다. 그래서 본문을 읽기 전에 실행되므로 가장 저렴한 계층이면서, 공격자와 바쁜 사무실을 구분하지 못하므로 가장 무딘 계층입니다. 도움이 될지 해가 될지는 세 가지로 결정됩니다. 횟수를 어디에 저장하는지, 무엇을 기준으로 세는지, 허용량이 얼마나 빨리 회복되는지입니다.

매끄러운 방문자별 제한에 한 방문자의 POST가 몰릴 때1~3번째 POST10초 안에: 수락4번째 POSTRetry-After와함께 42912초마다POST 하나 더 허용조용한 1분허용량 전부 회복버스트대기회복매끄러운 방문자별 제한에 한 방문자의 POST가 몰릴 때1~3번째 POST10초 안에: 수락4번째 POSTRetry-After와 함께 42912초마다POST 하나 더 허용조용한 1분허용량 전부 회복버스트대기회복
  1. 방문자가 10초 안에 POST 3건을 보냅니다. 모두 수락됩니다.
  2. 다음 POST는 얼마나 기다려야 하는지 알려 주는 Retry-After 헤더와 함께 429로 거부됩니다.
  3. 그 후에는 12초마다 POST가 하나씩 더 허용됩니다.
  4. 1분 동안 조용하면 허용량이 모두 돌아옵니다.
Ikiform의 Standard 수치입니다. 분당 5건, 10초에 최대 3건입니다. 매끄럽게 회복되므로 POST 하나를 더 보내려고 구간 전체를 기다릴 필요가 없습니다.

횟수를 저장하는 곳

저장소작동 환경주의할 점
메모리 내 Map오래 실행되는 서버 하나재시작하면 사라짐. 서버리스 인스턴스마다 맵이 따로 있어 실제 제한을 알 수 없음
Upstash 같은 Redis서버리스와 엣지를 포함해 어디서든POST마다 네트워크 호출 한 번. 서버와 가까운 리전을 고를 것
Cloudflare Workers 레이트 리밋 바인딩Workers근사치이며 Cloudflare 위치별로 집계. 기간은 10초 또는 60초만 가능
nginx limit_req 같은 리버스 프록시직접 운영하는 프록시 뒤의 서버프록시별로 집계. 실제 클라이언트 IP가 필요

알고리즘은 저장소보다 덜 중요하지만, 방문자가 느끼는 경험을 좌우합니다. 고정 구간은 매 분마다 초기화되므로, 스크립트가 한 분의 끝에 허용량 전부를 보내고 다음 분의 시작에 또 보낼 수 있습니다. 슬라이딩 구간은 이전 구간에 가중치를 주어 그 경계를 부드럽게 만듭니다. 토큰 버킷이나 Ikiform이 쓰는 비슷한 알고리즘은 POST를 하나씩 회복하므로, 오타를 낸 사람은 몇 초 뒤에 다시 보낼 수 있고 버스트의 문은 열리지 않습니다. 문의 폼이라면 수치가 사람들의 사용 방식에 맞기만 하면 어떤 것이든 괜찮습니다.

아래 예제는 Upstash의 @upstash/ratelimit을 사용합니다. 모든 프레임워크와 서버리스 호스트에서 똑같이 작동하기 때문입니다. UPSTASH_REDIS_REST_URL과 UPSTASH_REDIS_REST_TOKEN을 설정하고, 헬퍼를 한 번 저장하세요.

lib/limit.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const contact = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, "60 s"),
  prefix: "contact",
});

export async function allow(ip: string | null | undefined) {
  const { success, reset } = await contact.limit(ip || "no-ip");
  return { ok: success, retryAfter: Math.max(1, Math.ceil((reset - Date.now()) / 1000)) };
}

reset은 밀리초 단위의 Unix 시각이므로, 헬퍼가 Retry-After용으로 정수 초로 바꿉니다. IP가 없는 POST는 하나의 버킷을 공유하므로, 헤더가 없다고 해서 무사통과가 되지 않습니다.

프레임워크에 적용하기

일반 HTML 폼에는 무언가를 셀 서버가 없습니다. Ikiform으로 POST하고 폼에서 프리셋을 고르세요. 제한을 넘은 방문자에게는 오류 대신 짧은 속도 줄이기 안내 페이지가 표시됩니다.

contact.html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" required></textarea>
  <button>Send</button>
</form>

Cloudflare Workers에서는 내장 바인딩이 가장 저렴한 선택지입니다.

wrangler.toml
[[ratelimits]]
name = "CONTACT_LIMIT"
namespace_id = "1001"

  [ratelimits.simple]
  limit = 5
  period = 60
src/index.ts
const ip = request.headers.get("CF-Connecting-IP") ?? "no-ip";
const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
if (!success) return new Response("Too many messages", { status: 429, headers: { "Retry-After": "60" } });

Cloudflare는 이 바인딩이 근사치이며 위치마다 따로 집계된다고 설명하므로, 분산된 공격은 이를 넘을 수 있습니다. 위치를 가리지 않는 정확한 제한이 필요하다면 Cloudflare는 Durable Object를 권장합니다. 직접 운영하는 nginx 뒤라면 앱이 POST를 보기 전에 limit_req가 횟수를 셉니다.

nginx.conf
limit_req_zone $binary_remote_addr zone=contact:10m rate=5r/m;

server {
  location = /api/contact {
    limit_req zone=contact burst=3 nodelay;
    limit_req_status 429;
    proxy_pass http://127.0.0.1:3000;
  }
}

무엇을 기준으로 셀까

  • IP와 폼을 함께. 일반적인 선택입니다. 한 방문자가 이 폼을 폭주시킬 수 없고, 사이트의 다른 폼을 써도 같은 허용량이 줄지 않습니다.
  • 많은 사람, 하나의 IP. 사무실, 학교, 이동통신사, 컨퍼런스 Wi-Fi는 많은 사람을 하나의 주소 뒤에 둡니다. IP별 제한을 그들이 쓸 수 있을 만큼 느슨하게 두거나, 행사 당일에는 올리세요.
  • 실제 클라이언트 IP. 프록시나 CDN 뒤에서는 호스트가 설정하는 헤더를 읽고 그것만 신뢰하세요. 원시 X-Forwarded-For 값은 클라이언트가 설정할 수 있습니다.
  • 여러분 서버의 IP. 서버가 POST를 다른 서비스로 전달하면, 그 서비스에서는 모든 방문자가 서버의 IP를 공유합니다. 전달하기 전에 여러분 쪽에서 방문자의 IP 기준으로 제한하세요.

Ikiform의 레이트 리밋

Ikiform은 본문을 읽기 전에 방문자 IP와 폼별로 모든 POST를 정확히 세며, 고정 구간이 아니라 매끄러운 회복 방식을 씁니다. 폼마다 Rate limit에서 프리셋을 고릅니다. 모든 폼에 걸쳐 IP당 분당 600건의 폭주 방어가 그 무엇보다 먼저 실행됩니다.

프리셋방문자당어울리는 곳
Standard분당 5건, 10초에 최대 3건대부분의 폼
Relaxed분당 20건공유 Wi-Fi를 쓰는 행사, 로그인 뒤의 폼
Strict10분에 2건, 이후 5분마다 1건 추가문의 폼과 영업 폼
Custom10초, 1분, 10분, 1시간, 1일당 1~1,000건1인 1회 응모, 키오스크

Custom 상한을 정하지 않으면 폼 전체로는 모든 방문자를 합쳐 분당 300건까지 받습니다. 제한을 넘으면 브라우저 POST에는 속도 줄이기 안내 페이지가 표시되고, fetch는 Retry-After와 함께 429 rate_limited를 받습니다. Ikiform은 이 헤더를 CORS로 노출하므로 위 코드가 읽을 수 있습니다. 회복 방식은 레이트 리밋 문서에서 설명합니다.

서버 측 버전은 시크릿 키로 POST하므로 Ikiform은 Allowed domains와 자체 사람 확인을 건너뜁니다. 그러면 모든 POST가 서버의 IP에서 오고, 방문자당 분당 5건인 Standard 제한이 모든 방문자를 합쳐서 셉니다. 서버를 거쳐 POST하는 폼에는 Relaxed 또는 Custom 레이트 리밋을 고르고, IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.

작동 확인하기

  1. 폼을 연속으로 여러 번 제출하세요. 제한을 넘으면 Retry-After 헤더와 함께 429를 받거나 Ikiform의 속도 줄이기 안내 페이지가 표시됩니다.
  2. Retry-After에 적힌 초만큼 기다린 뒤 다시 제출하세요. 수락됩니다.
  3. Wi-Fi를 끈 휴대폰처럼 다른 네트워크에서 제출하세요. 별도의 허용량이 있습니다.
  4. 서버가 POST를 전달한다면 두 기기에서 빠르게 제출하고, 서로의 횟수를 공유하지 않는지 확인하세요.

주의할 점

  • 거부된 POST가 허용량을 쓰면 안 됩니다. 작업 전에 세고, 거부된 POST가 구간을 더 뒤로 밀지 않게 하세요. 그렇지 않으면 조급한 방문자가 스스로를 잠가 버립니다.
  • 페이지 로드는 제한하지 마세요. 폼 조회가 아니라 제출을 세세요. 그렇지 않으면 페이지를 새로 고치기만 해도 보낼 수 있는 횟수가 줄어듭니다.
  • 429 rate_limited 한 방문자가 폼의 레이트 리밋보다 많이 보냈습니다. Standard는 방문자당 분당 5건을 허용합니다. Retry-After만큼 기다리거나, 하나의 공유 IP에서 POST하는 폼에는 Relaxed 또는 Custom을 고르세요.
  • IPv6. 한 사람이 IPv6 주소를 많이 가질 수 있습니다. 엄격하게 제한하려면 전체 주소가 아니라 /64 접두사 기준으로 세세요.

질문

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