Honeypot + time-trap: невидимая защита форм без капчи

Веб Штурм
  • security
  • формы
  • honeypot
  • time-trap
  • 152-ФЗ

Капча — самый дорогой UX-элемент на форме: ~5% реальных пользователей бросают заполнение, 1-2% не справляются с challenge’ем. На небольших B2B-сайтах с парой десятков заявок в день это лишняя плата за защиту, которой можно достичь дешевле. Honeypot + time-trap отсекают около 80% автоматических спам-ботов без единого пикселя в UI.

Контекст

Большинство форм-спама в 2026 году — это автоматические скрипты, которые либо парсят DOM целиком и заполняют все <input>, либо submit’ят форму через fetch за миллисекунды после загрузки страницы. Оба сценария ловятся примитивными серверными проверками без какого-либо ML или капчи.

На ozsm.ru мы поставили эту связку на формы /kontakt и /zayavka ещё до замены reCAPTCHA на Yandex SmartCaptcha — и именно она отсеивала большую часть спама. SmartCaptcha добавили позже как страховку для оставшихся 20%, и для compliance с 152-ФЗ.

Honeypot: невидимое поле-ловушка

Идея: добавить в форму поле, которое реальный пользователь никогда не заполнит, потому что не видит его. Бот, парсящий DOM «всё в один заход», заполнит. На сервере проверяем — если поле непустое, отбрасываем submit.

<input
  type="text"
  name="website"
  class="absolute -left-[9999px] opacity-0"
  tabindex="-1"
  autocomplete="off"
  aria-hidden="true"
/>

Пять защит сразу:

  1. absolute -left-[9999px] — поле физически за пределами вьюпорта. CSS-only, не влияет на layout.
  2. opacity-0 — даже если CSS подгрузился частично, поле не видно
  3. tabindex="-1" — пользователь с клавиатуры не доскачет туда Tab’ом
  4. autocomplete="off" — браузерный автозаполнятель не будет его трогать
  5. aria-hidden="true" — скринридер не прочитает (не сбивает слепых пользователей)

Почему display: none хуже. Часть продвинутых ботов (Headless Chrome) умеет фильтровать display: none поля как «honeypot, не трогать». position: absolute в офф-вьюпорт — менее очевидная ловушка.

Почему имя website (а не bot_trap). Боты часто заполняют именно «website» автоматически — это распространённое поле в формах подписки. Оно работает как магнит.

Серверная проверка — одна строка:

if (formData.get('website')) {
  return { ok: false, code: 'honeypot' };
}

Time-trap: проверка времени заполнения

Идея: бот сабмитит форму за миллисекунды, человек — за десятки секунд. Записываем время загрузки формы в скрытое поле, на сервере проверяем разницу.

Клиентская часть (Svelte 5 runes):

<script lang="ts">
  let mountedAt = $state(0);

  $effect(() => {
    mountedAt = Date.now();
  });
</script>

<form>
  <input type="hidden" name="form_ts" value={mountedAt} />
  <!-- ...остальные поля -->
</form>

$effect запускается на mount компонента. Свежее время кладётся в hidden поле. На submit оно уйдёт вместе с формой.

Серверная часть:

const formTs = Number(formData.get('form_ts'));
const elapsed = Date.now() - formTs;

if (!formTs || elapsed < 3000) {
  return { ok: false, code: 'too_fast' };
}

if (elapsed > 30 * 60 * 1000) {
  return { ok: false, code: 'too_old' };
}

Два порога:

3 секунды — эмпирический порог: меньше = слишком много false-positive (быстрые пользователи на простых формах), больше = пропускаешь медленных ботов.

Server-side ОБЯЗАТЕЛЬНО

Главная ошибка — проверять honeypot/time-trap на клиенте. Бот пропустит JS вообще или просто не выполнит ваш if. Проверки только на сервере:

// app/src/lib/contact-handler.ts
export async function validateAndProcessContact(
  formData: FormData,
  opts: { verifyCaptcha: (t: string) => Promise<boolean>; sendEmail: (d: any) => Promise<void> }
): Promise<{ ok: boolean; code?: string; message?: string }> {
  // 1. Honeypot
  if (formData.get('website')) {
    return { ok: false, code: 'honeypot' };
  }

  // 2. Time-trap
  const formTs = Number(formData.get('form_ts'));
  if (!formTs || Date.now() - formTs < 3000) {
    return { ok: false, code: 'too_fast' };
  }

  // 3. Captcha (если включена)
  const token = formData.get('smart-token') as string;
  if (token && !(await opts.verifyCaptcha(token))) {
    return { ok: false, code: 'captcha' };
  }

  // 4. Send
  await opts.sendEmail({ /* ... */ });
  return { ok: true };
}

Чистая функция с инъекцией зависимостей — тестируется vitest без mock’а HTTP-сервера.

Когда хватит без капчи (и когда нужна)

Хватит:

Уже мало:

Для нас на ozsm.ru было «хватит», но мы всё равно поставили SmartCaptcha — не из-за объёма спама, а из-за 152-ФЗ: с 1 июля 2025 reCAPTCHA в РФ запрещена для обработки ПД, штраф 1-6 млн ₽. Раз уж меняем, поставили лучшее доступное.

Что мы из этого вынесли

Ссылки