Каждый туториал по контактной форме Next.js включает rate limiting. Большинство рекомендуют Upstash Redis со скользящим окном, 3 отправки в минуту на IP. Я отгрузил этот паттерн, прогнал месяц и удалил.
Это пост-компаньон к четырёхстрочному спам-фильтру, заменившему reCAPTCHA на этом сайте. Та же контактная форма. Ещё одно упрощение.
Что у меня было
Каноничный стек «безопасной контактной формы»:
- Honeypot + time-floor + лимиты размера (пост по ссылке выше)
- Upstash Redis rate limiter, 3/мин на IP
- Cloudflare перед всем
- Telegram-уведомление при валидной отправке
Примерно 80 строк кода плюс Redis-зависимость плюс Cloudflare worker. Для контактной формы, получающей ~5 отправок в неделю.
Что я заметил через месяц
Три точки данных заставили пересмотреть.
1. Rate limiter ни разу не сработал на реальной атаке.
Honeypot и time-floor ловили каждую спам-попытку, которую я мог подтвердить. Я грепнул логи на «rate limit exceeded» — ноль за 31 день. Боты, пытавшиеся отправить, все фейлились на honeypot или time-floor до того, как доходили до rate limit. Rate limiter стоял после фильтров, которые реально работали.
2. Он сработал на двух легитимных пользователях.
Оба раза — люди в корпоративных сетях. Большой офис, общий IP, один человек отправляет за себя, другой — сразу после. Вторая отправка попала в лимит 3/мин. Они получили вежливый «попробуйте позже» и, вероятно, не попробовали.
Два потерянных лида на форме с ~20 отправками в месяц — это 10% ложных срабатываний на штуке, которая должна ловить ботов. Это хуже, чем не ограничивать.
3. Redis добавил режим отказа.
Upstash отличный. Но это ещё одна зависимость, ещё один API-вызов на отправку, ещё одно место, которое может тормозить или быть недоступным. Дважды за месяц ответ Upstash был >500ms, что сделало отправку ощутимо тормозной. Один раз (кратко) Upstash вернул ошибку, которую мой код обработал как fail open — rate limit не применился в ту минуту. На этом этапе: что он реально защищал?
Математика, когда rate limiting окупается
Rate limiting реально полезен, когда:
- Эндпоинт формы делает дорогую работу. Отправка письма дёшева (Resend обрабатывает). ML-модель — нет. Если каждая отправка стоит 50ms CPU или €0.002 API-вызова, ограничивай.
- Тебя целенаправленно атакует конкретный атакующий. Один IP, долбящий 500 раз в минуту — ровно то, для чего нужны rate limiter'ы. Cloudflare обычно ловит это на edge, но application-level rate limiting — ремень плюс подтяжки.
- Эндпоинт создаёт пользователевидимое состояние. Форма регистрации, создающая аккаунты, или форма комментариев, публикующая публично, нуждается в rate limiting как в защите от абьюза — потому что дубликаты не просто раздражают, они публичны.
- Комплаенс или аудит требует. Некоторые enterprise-клиенты спросят. Это реальная причина, даже если математика иначе не поддерживает.
Rate limiting — рефлекс-без-окупаемости, когда:
- Форма уже ограничена upstream. Cloudflare на 100 req/мин на IP ловит 99% абьюза до того, как твой код это видит. Добавление второго слоя на 3/мин на IP — обычно трение без маржинальной защиты.
- Эндпоинт уже фильтруется дешёвыми контент-проверками. Мой honeypot + time-floor отвергает ботов за <1ms без downstream-стоимости. Rate limiting стоит после фильтров, которые реально решают.
- Объём низкий. Портфолио-форма на 5 отправок в неделю не будет DDoS'ена мотивированным противником до Stripe-уровня счетов. Худший случай — несколько сотен спам-отправок за ночь — неудобство, не инцидент.
Чем я заменил
Ничем. Цепочка фильтров теперь:
- Cloudflare на edge (поставил и забыл)
- Honeypot
- Time-floor (минимум 1500ms)
- Лимиты размера (
name < 100,email < 200,message < 5000) - Telegram-уведомление
Весь пайплайн. Нет Redis, нет состояния, нет скользящего окна. 60ms p95 время ответа.
Худший случай, в котором я могу ошибаться
Мотивированный атакующий мог бы, в теории, заскриптовать headless-браузер, который монтирует страницу, ждёт 1600ms, заполняет форму и отправляет — обходя time-floor. Теперь у меня бот, который отправляет 100 раз в минуту.
Что реально происходит тогда:
- Telegram начинает вибрировать. Я вижу.
- Я добавляю rate limiting за 15 минут. Код всё ещё в git history; просто
git revertудаления. - Или баню IP-диапазон в Cloudflare.
Ключевой инсайт: rate limiting — это фикс для конкретной формы атаки. Я могу добавить его обратно меньше чем за час, когда увижу атаку. Платить за него превентивно, навсегда, на форме, которой он никогда не был нужен — это налог на угрозу, которая не материализовалась.
Обобщённое правило
Раньше я добавлял каждый security-middleware из топового туториала. Теперь я задаю три вопроса перед добавлением:
- От какой конкретной атаки это защищает? Если не могу назвать одним предложением — защита не нужна.
- Во что обходится — в сложности кода, в зависимостях, в латенси, в ложных срабатываниях? Каждая защита — это налог.
- Могу ли я добавить это позже, когда атака реально появится? Для большинства защит на малотрафиковых системах ответ — да, и превентивная защита — перерасход.
Rate limiting на контактной форме провалил все три для меня:
- Атака, от которой он защищает, в основном ловится более дешёвыми upstream-фильтрами.
- Он добавил Redis, ~20 строк кода и 2 ложных срабатывания на 20 отправок.
- Я абсолютно могу добавить его обратно за час, когда (если) реальный атакующий появится.
Удалён. Не скучаю.
Оговорка
Это работает, потому что форма моя, объём маленький, и downstream-действие (Telegram-уведомление мне) не имеет публичных побочных эффектов. Если ты строишь:
- Высокотрафиковый публичный API
- Форму регистрации, создающую пользовательские аккаунты
- Форму комментариев, публикующую публично
- Эндпоинт, смежный с платежами
- Форму, триггерящую SMS (реальная стоимость на отправку)
— оставляй rate limiter. Он существует по причине, и причина — форма твоего конкретного случая.
Этот пост конкретно о малотрафиковых, owner-viewed, без побочных эффектов эндпоинтах форм. Интернет в основном пишет гайды «защита по умолчанию», что правильно для медианного проекта и неправильно для конкретных. Знать, когда «конкретный» применим к твоему проекту — это как ты перестаёшь платить за защиту, которая не нужна.
Честный трейд, который я делаю
Я меняю defence-in-depth против атаки, которая не случилась, на более простую кодовую базу с меньшим количеством зависимостей. Этот трейд правильный для этой формы сегодня. Он был бы неправильным для другой формы завтра.
Режим отказа, если я ошибаюсь: выходные спама в Telegram и пятнадцать минут git revert. Я переживу.
Режим отказа, если бы я оставил rate limiter: два потерянных реальных разговора в месяц, навсегда, молча. Не могу.
Если ты ревьюишь свой собственный security-стек и хочешь второе мнение, что оставить, а что удалить — я прочитаю твою модель угроз и отвечу одной страницей честной обратной связи. Не пытаюсь продать аудит — в половине случаев мой ответ «вы перетрачиваете». Контактная форма.