# 스팸 방지

> 허니팟, 서명된 타임스탬프, 스팸 점수, 사람 확인, 허용 도메인, 레이트 리밋.

모든 폼은 별도 설정 없이 여러 겹의 보호를 받습니다. 허니팟, 서명된 렌더링 타임스탬프, 스팸 점수, 레이트 리밋이 기본입니다. 여기에 사람 확인을 추가하고 폼을 여러분의 도메인으로 제한할 수 있습니다.

| 보호 계층 | 기본 활성화 | 결과 |
| --- | --- | --- |
| [레이트 리밋](https://ikiform.com/ko/docs/rate-limits.md) | 예, Standard 프리셋 | `429 rate_limited` |
| [허용 도메인](#allowed-domains) | 아니요 | 스팸 또는 `403 origin_not_allowed` |
| [사람 확인](#human-check) | 아니요 | `403 challenge_required` 또는 확인 페이지 |
| [허니팟](#honeypot) | 예 | 점수 +5 |
| [스팸 점수](#scoring) | 예 | 5점 이상이면 스팸 |

## 스팸 처리 방식

스팸으로 판정된 POST는 `spam` 상태로 저장되며 여전히 정상적인 성공 응답을 받으므로, 봇은 아무것도 알 수 없습니다. 스팸은 웹훅과 이메일을 보내지 않고, 월간 사용량에 포함되지 않으며, 30일 후 삭제됩니다. 대시보드나 [REST API](https://ikiform.com/ko/docs/api.md#mark-spam)로 검토하세요. 제출을 스팸이 아님으로 표시하면 그때 웹훅이 전송됩니다.

## 허니팟

사람은 절대 채우지 않는 숨겨진 입력을 추가하세요. 기본 이름은 `_gotcha`이며, 폼 설정의 **Honeypot field**에서 폼마다 바꿀 수 있습니다. 숨기는 방법은 [HTML 폼](https://ikiform.com/ko/docs/html.md#honeypot)에 있습니다.

`Honeypot`:

```html
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
```

## 스팸 점수

각 POST는 다음 신호로 점수를 받습니다. 5점 이상이면 스팸으로 저장됩니다.

| 신호 | 대시보드에 표시되는 사유 | 점수 |
| --- | --- | --- |
| 허니팟이 채워짐 | `honeypot` | +5 |
| 폼이 렌더링된 후 2초 안에 전송됨 | `timestamp_fast` | +3 |
| 서명된 렌더링 타임스탬프가 위조되었거나 24시간보다 오래됨 | `timestamp_invalid` | +3 |
| 이름이나 제목 필드에 링크가 있음 | `url_in_name` | +3 |
| 최근 24시간 안에 같은 필드가 이 폼으로 전송됨 | `duplicate` | +3 |
| 링크가 모두 3개 이상 | `many_urls` | +2 |
| 알려진 스팸 문구 (기본 22개 내장) | `keyword` | +2 |
| 일회용 이메일 도메인의 이메일 주소 | `disposable_email` | +2 |

- 렌더링 타임스탬프는 서명된 `_ikiform_ts`를 보내는 SDK나 [shadcn/ui 컴포넌트](https://ikiform.com/ko/docs/shadcn.md)가 폼을 렌더링할 때만 존재합니다. 타임스탬프가 없는 일반 HTML POST는 감점되지 않습니다.
- 이름 필드는 이름에 `name`, `firstname`, `lastname`, `fullname`, `surname`, `subject`가 들어 있는 필드이며, `company`, `website`, `domain` 같은 단어는 제외됩니다.
- 점수와 관계없이 스팸으로 처리되는 사유가 두 가지 있습니다. [허용 도메인](#allowed-domains)에서 오는 `origin_mismatch`와, Turnstile에 연결할 수 없을 때의 `captcha_unavailable`입니다.

## 사람 확인

각 폼에는 세 가지 옵션이 있는 **Spam check** 설정이 있습니다. 기본값은 꺼짐이므로, 폼을 만들어도 기존 `fetch`와 curl 연동은 계속 작동합니다.

| 옵션 | 하는 일 |
| --- | --- |
| Off | 챌린지 없음. 허니팟, 타임스탬프, 점수 산정은 계속 실행됩니다. |
| Ikiform (권장) | Ikiform이 ikiform.com에서 실행하는 Cloudflare Turnstile. Cloudflare 계정이나 키가 필요 없습니다. |
| Your own Turnstile keys | 여러분의 Turnstile 위젯과 시크릿 키. 토큰의 호스트 이름을 허용 도메인과 비교합니다. |

### Ikiform 확인의 실행 방식

토큰을 얻는 방법은 제출 방식에 따라 다릅니다.

- **HTML 폼 POST:** 토큰 없는 POST는 ikiform.com의 짧은 "Checking you're human" 페이지를 받습니다. 이 페이지가 Turnstile을 실행하고 원래 필드를 토큰과 함께 다시 POST한 뒤, 브라우저는 평소처럼 리디렉션을 따릅니다.
- **JavaScript:** SDK의 `challenge(formId)`를 호출하세요. Turnstile에 클릭이 필요할 때만 보이는 작은 ikiform.com 프레임을 마운트하고, 토큰으로 resolve됩니다. 이 토큰을 `submit`에 `turnstileToken`으로 전달하세요. 토큰 없는 `fetch` POST는 `403 challenge_required`를 받습니다.
- **shadcn/ui 컴포넌트:** 알아서 처리됩니다.
- **시크릿 키를 쓰는 서버:** 서버는 확인을 풀 수 없으므로 건너뜁니다.

`form.js`:

```js
import { challenge, submit } from "@ikiform/sdk";

const form = document.querySelector("form");

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const turnstileToken = await challenge("frm_xxxxxxxxxxxxxxxx");
  const result = await submit("frm_xxxxxxxxxxxxxxxx", new FormData(form), { turnstileToken });
  if (result.ok) form.replaceWith("Thanks, we got it.");
});
```

토큰은 한 번만 쓸 수 있고 300초 동안 유효하므로 제출마다 하나씩 받으세요. 토큰 없는 JSON POST는 `challenge_url`과 함께 `403 challenge_required`를 받고, 검증에 실패한 토큰은 `403 captcha_failed`를 받습니다.

`403 Forbidden`:

```json
{
  "ok": false,
  "error": {
    "code": "challenge_required",
    "message": "Complete the human check, then send the form again.",
    "challenge_url": "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx/challenge"
  },
  "request_id": "req_01k7v2c9t3e8m5q4x6z0a1b2c3"
}
```

> Turnstile 자체에 연결할 수 없으면 POST는 받아들여지고 `captcha_unavailable` 사유와 함께 스팸으로 저장되므로, 잃어버리지 않고 복원할 수 있습니다.

### Your own Turnstile keys

사이트 키와 시크릿 키를 폼 설정에 붙여 넣으세요. 시크릿은 암호화되어 저장됩니다. 폼에 위젯을 렌더링해 POST에 `cf-turnstile-response`가 추가되도록 하거나, 토큰을 `submit`에 `turnstileToken`으로 전달하세요. 올바른 토큰이 없는 POST는 시크릿 API 키를 쓴 POST를 포함해 모두 `403 captcha_failed`를 받습니다.

## 허용 도메인

폼이 있는 도메인을 **Allowed domains**에 등록하세요. 그러면 POST는 `Origin` 헤더(출처가 없으면 `Referer`)를 기준으로 그 도메인이나 하위 도메인에서 와야 합니다. 목록을 비워 두면 어떤 사이트의 POST든 받습니다.

| 허용 도메인 | POST 출처 | 일치 여부 |
| --- | --- | --- |
| `example.com` | `https://example.com` | 예 |
| `example.com` | `https://www.example.com` | 예, 하위 도메인 |
| `example.com` | `http://localhost:3000` | 아니요, 개발 중에는 `localhost`를 추가하세요 |
| `example.com` | `https://example.co` | 아니요 |
| `app.example.com` | `https://example.com` | 아니요, 상위 도메인은 일치하지 않습니다 |

**When a post comes from another site** 설정이 불일치 시 동작을 정합니다. **Mark as spam**(기본값) 또는 `403 origin_not_allowed`로 **Reject**합니다. 시크릿 API 키를 쓴 POST는 이 검사를 건너뜁니다. 허용 도메인은 어떤 `_redirect` 대상을 따를지, Ikiform 확인 프레임을 어디에 임베드할 수 있는지도 결정합니다.

## 레이트 리밋

각 폼에는 방문자 IP와 폼별로 정확하게 세는 **Rate limit** 프리셋이 있습니다. Standard는 분당 5건, Relaxed는 분당 20건, Strict는 10분마다 2건을 허용합니다. 전체 규칙은 [레이트 리밋](https://ikiform.com/ko/docs/rate-limits.md)에 있습니다.
