Контактная форма на этом сайте стреляет Telegram-уведомлением при каждой отправке. Когда отгружал, думал, что понадобится reCAPTCHA — каждый гайд по защите от спама в интернете так говорит. Попробовал неделю, ненавидел трение, выдрал.
Два месяца спустя, без reCAPTCHA, инбокс всё ещё чист. Вот паттерн.
Весь фильтр, в коде
// app/api/telegram/route.ts
const data = await request.json()
const { website, mountedAt, name, email, message } = data
// 1. Honeypot — реальные пользователи никогда не видят это поле
if (website && website.trim().length > 0) {
return NextResponse.json({ success: true }) // молча принимаем
}
// 2. Time-floor — боты отправляют мгновенно, людям нужно набирать
if (typeof mountedAt === 'number' && Date.now() - mountedAt < 1500) {
return NextResponse.json({ success: true })
}
// 3. Лимиты размера — защищаем downstream API от абьюза пэйлоада
if (name.length > 200 || email.length > 200 || message.length > 5000) {
return NextResponse.json({ error: 'Payload too large' }, { status: 413 })
}
Вот и всё. Три гарда, все серверные. Без клиентского скрипта.
Почему каждый важен
Honeypot — скрытое поле формы. <input name="website" tabindex="-1" autocomplete="off" style={{display:'none'}} /> на стороне React. Реальные браузеры его не рендерят, реальные пользователи не заполняют. Большинство спам-ботов обходят DOM, видят input и старательно заполняют каждый. В момент, когда website имеет значение — ты знаешь, что это бот. Критичная деталь: не возвращай ошибку. Возвращай { success: true }. Боты, следящие за 4xx/5xx, будут ретраить; боты, получившие 200, пометят форму как «готово» и уйдут.
Time-floor ловит остальное. Форма отправляет timestamp маунта в скрытом поле при рендере. Когда приходит отправка, всё, что меньше 1.5 секунд между маунтом и отправкой — бот. Люди физически не могут набрать имя, email и сообщение так быстро. Тот же трюк: возвращаем 200 молча, чтобы бот не научился отступать.
Лимиты размера — не столько анти-спам, сколько защита от абьюза. Без них скучающий атакующий мог бы POST'нуть 50MB тело и разогнать квоту Telegram API. Кэпай каждое поле до разумного максимума и возвращай 413 для oversize.
Почему без reCAPTCHA
reCAPTCHA решает реальную проблему неправильным способом для этого юзкейса. Она:
- Загружает ~100KB стороннего JavaScript на каждую страницу — большая часть которого — fingerprinting посетителей, чтобы Google мог оценить их позже.
- Добавляет раскрытие приватности на страницу — юридически нужна сноска о трекинге Google, что совершенно неправильный тон для «напишите мне».
- Трение: даже невидимая reCAPTCHA фейлится для некоторых пользователей на VPN, в приватном режиме или на Linux/Firefox. Эти пользователи — непропорционально разработчики, твои потенциальные клиенты — видят галочку или хуже, пазл. Часть из них сдаётся.
Для малообъёмной B2B контактной формы математика просто не работает. Стоимость reCAPTCHA (потерянные легитимные отправки) выше пользы (фильтрация спама, который honeypot и так ловит).
Когда это перестанет работать
Я бы пересмотрел в момент, когда что-то из этого станет правдой:
- Целенаправленные атаки, а не generic спам — человек, пишущий кастомный код для обхода конкретно твоего фильтра. Четырёхстрочный фильтр — для скрапера, обходящего 100K форм в день, а не для того, кто пытается спамить тебе конкретно.
- Высокообъёмные формы — подписки на рассылку, формы регистрации, что угодно с серьёзными деньгами за ним. Там стоимость false-negative достаточно высока, чтобы оправдать более умную систему (Cloudflare Turnstile, Friendly Captcha, или полноценный rate limiting на Redis).
- Authenticated abuse — когда спам идёт от залогиненных аккаунтов, фильтр формы — не тот слой. Перемещай на создание аккаунтов.
Для всего между — портфолио, маленькая SaaS контактная страница, CTA «расскажите больше» — начинай с четырёхстрочной версии. Добавляй сложность только если реально сломается.
Полная клиентская часть на React
Для полноты, React-сторона, которая питает time-floor и honeypot:
const [mountedAt] = useState(() => Date.now())
return (
<form onSubmit={handleSubmit}>
{/* honeypot — визуально + семантически скрыт */}
<input
type="text"
name="website"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
className="absolute left-[-9999px]"
/>
<input type="hidden" name="mountedAt" value={mountedAt} />
{/* реальные поля */}
<input name="name" required />
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit">Отправить</button>
</form>
)
Три вещи, которые нужно сделать правильно:
- Не используй
display: noneна honeypot. Некоторые боты проверяют computed style и пропускают. Используй absolute positioning за пределами экрана — выглядит пустым для людей, выглядит реальным полем для краулеров. - Mount timestamp — per-render, не per-session. Если закешируешь в localStorage — сломаешь time-floor для легитимных возвращающихся пользователей.
aria-hiddenне даёт скринридерам объявить honeypot. Они бы и так пропустилиdisplay:noneполе, но absolute-positioned inputs безaria-hiddenмогут сбить ассистивные технологии.
Урок, если он есть: защита от спама — это прежде всего проблема UX. «Правильный» ответ в абстракции — самый тяжёлый, самый умный фильтр — часто неправильный ответ, когда учтёшь стоимость, которую он накладывает на легитимных пользователей. Для контактной формы на личном сайте четырёх строк серверной проверки хватает. Капчи береги для угроз, которые реально их заслуживают.