Модуль ratelimit в rspamd: раздельные лимиты по отправителю, IP и получателю
Один глобальный лимит в rspamd не спасёт репутацию IP, когда взломают SASL-аккаунт. Разбираем модель дырявого ведра, семантику встроенных лимитов, которую все понимают неправильно, и раздельные политики для исходящего и входящего трафика — с рабочими конфигами и отладкой через Redis.
EvilMail Team21 июля 2026 г.12 мин чтения
Проблема: один глобальный лимит ломает всё
Классический инцидент выглядит так. В три часа ночи фишингом уводят пароль от одного почтового ящика. Скомпрометированный аккаунт логинится на submission-порт (587) с валидным SASL, и за 20 минут через ваш сервер уходит 4000 писем на купленную базу. К утру IP лежит в Spamhaus CSS и Barracuda, а вся легитимная почта с этого адреса получает tempfail от Gmail и Outlook.
Самое обидное: в конфиге стоял лимит. to_ip_from = "100 / 1m" — «сто писем в минуту, чего ещё». Он не сработал, потому что считал по неправильному ключу. to_ip_from — это связка «получатель + входящий IP + отправитель», лимит на входящий поток. Для аутентифицированной исходящей почты входящий IP — это ваш собственный submission-листенер, отправитель размазан по тысячам разных получателей, и ни одно ведро не переполнилось до порога. Взломанный аккаунт ловится только лимитом с ключом по SASL-логину. Его в конфиге не было.
Важно понимать, где ratelimit стоит в конвейере. Это prefilter — он отрабатывает до основных правил, до Bayes, до SPF/DKIM/DMARC. И при переполнении он не начисляет баллы к спам-скору, а сразу возвращает действие soft reject
, то есть SMTP-ответ
451 4.7.1 Try again later
. Это принципиально. Балльный символ — это «улика», которую ещё сложат с другими и, может быть, отклонят. Soft reject — жёсткий тормоз на входе: письмо не проходит дальше, но и не теряется — легитимный клиент повторит попытку позже, а бот или скрипт рассылки чаще всего захлебнётся на очереди. Ratelimit — не детектор, а кран.
Дырявое ведро: burst и rate, а не «X писем в минуту»
Модель, на которой держится весь модуль, — leaky bucket, дырявое ведро. Письма льются сверху, из дырки внизу вытекают с постоянной скоростью. У ведра два параметра, и путать их — корень большинства кривых настроек:
`burst` — ёмкость ведра. Сколько писем можно пропустить одним всплеском, пока ведро не переполнилось.
`rate` — скорость утечки. Какой устойчивый поток вы разрешаете держать бесконечно.
Почему это лучше фиксированного окна (fixed-window, «сбрасываем счётчик каждую минуту»)? Фиксированное окно даёт зазубрины: на стыке двух окон можно пропихнуть два burst подряд, а внутри окна оно жёстко режет легитимный всплеск. Дырявое ведро сглаживает: короткий всплеск проходит целиком, а держать поток выше rate бесконечно нельзя — ведро наполнится и начнёт переливаться.
На числах: burst = 100; rate = "2 / 1s". Первые 100 писем пролетают мгновенно (рассылка новостей клиенту — ок). Дальше сервер пропускает по 2 письма в секунду — ровно с той скоростью, с какой ведро подтекает. Скрипт, который льёт 50 писем в секунду, упрётся в 451 на сто первом сообщении.
Синтаксис есть старый и новый. Старый — одно число на всё:
# старый синтаксис: 50 — это ОДНОВРЕМЕННО и burst, и rate
to_ip_from = "50 / 1m";
Проблема очевидна: burst и rate слиты, гибкости ноль. Новый синтаксис (rspamd 2.x/3.x) разделяет их явно, и только его стоит использовать:
Ratelimit без Redis молча не работает. Состояние каждого ведра (текущий уровень, время последней утечки) — это общий стейт, который переживает перезагрузку воркеров и шарится между процессами. Хранится он в Redis. Нет backend — модуль просто ничего не делает, и вы об этом не узнаете, пока не случится инцидент.
Заводите отдельную БД, а не общую свалку. Greylist, replies и ratelimit на одной db с непонятной нагрузкой — это гонки за память и невозможность что-либо отладить:
Ключи в Redis имеют формат RL_<имя_ведра>_<хеш>, где хеш — это то самое значение ключа (получатель, IP, SASL-логин). Инспекция состояния:
bash
# все ведра ratelimit в db 3
redis-cli -n 3 --scan --pattern 'RL_*'
# сколько живёт конкретное ведро
redis-cli -n 3 TTL 'RL_to_ip_from_a1b2c3...'
# содержимое ведра (уровень + timestamp)
redis-cli -n 3 HGETALL 'RL_user_deadbeef...'
TTL проставляется автоматически исходя из rate: пустое ведро со временем выпадает из Redis, чтобы не копить мусор по каждому одноразовому отправителю.
Встроенные лимиты и что они РЕАЛЬНО считают
Половина кривых конфигов — из-за того, что имена лимитов интуитивно понимают неправильно. Ключ (то, по чему письма группируются в одно ведро) у каждого свой:
Имя
Ключ группировки
Когда срабатывает
to
получатель
много писем на один адрес
to_ip
получатель + входящий IP
один IP заваливает одного получателя
to_ip_from
получатель + IP + отправитель
самый узкий, для входящего MX
user
SASL-логин
только для аутентифицированных
bounce_to
получатель при пустом MAIL FROM (<>)
шторм баунсов
bounce_to_ip
получатель + IP, пустой sender
то же + привязка к IP
Встроенные ведра по умолчанию выключены — они работают только после того, как вы явно опишете их в блоке rates. И вот ключевой момент, ради которого написана статья: `user` — единственный лимит, который ловит скомпрометированный аккаунт. Его ключ — SASL-логин, а не IP и не получатель. Скольким бы разным адресатам взломанный ящик ни слал, всё падает в одно ведро с ключом по логину. Именно этого не хватало в инциденте из начала.
bounce_to тоже недооценивают. Backscatter-атака и петли рассылки идут с пустым MAIL FROM: <>, и обычные to-лимиты считают их вперемешку с нормальной почтой. Отдельное ведро на баунсы отсекает шторм, не трогая живой трафик.
Разные политики для исходящего и входящего
Это практическое ядро. Аутентифицированный отправитель на 587 и анонимный MX на 25 — противоположные угрозы, и лимиты у них должны быть противоположные.
Для исходящего (submission) угроза — компрометация аккаунта. Тут ведро на user должно быть жёстким: живой человек не шлёт 500 писем в час руками. Ставьте лимит по реальному профилю пользователя с небольшим запасом — и взлом упрётся в 451 через минуты, а не через утро.
Для входящего (MX) угроза — ботнет и bomb-рассылка. Тут лимит должен быть мягким: за одним входящим IP может стоять легитимный крупный отправитель (Mailchimp, корпоративный шлюз), и слишком узкое ведро на to_ip_from порежет нормальную почту. Зато отдельное ведро на to защищает конкретный ящик от заваливания.
hcl
# /etc/rspamd/local.d/ratelimit.conf
rates {
# ЖЁСТКО: защита от взлома SASL-аккаунта
authenticated {
selector = 'user';
bucket = {
burst = 40;
rate = "20 / 1h";
}
}
# МЯГКО: входящий MX, не резать крупные легитимные домены
to_ip_from {
bucket = {
burst = 300;
rate = "100 / 1m";
}
}
# защита одного ящика от bomb-рассылки
to {
bucket = {
burst = 100;
rate = "50 / 1m";
}
}
}
whitelisted_rcpts = "postmaster,mailer-daemon";
max_rcpt = 5;
info = true;
Селектор user возвращает значение только для аутентифицированных сессий, поэтому ведро authenticated на анонимный MX-трафик просто не применяется — оно бьёт исключительно по submission.
Для temp-mail-инфраструктуры вроде evilmail.pro лимит to обязателен. Одноразовый ящик засветился на форуме, и кто-то решил его «утопить», отправив тысячу писем за минуту. Ведро на получателя гасит такую атаку, не затрагивая остальные ящики домена.
Селекторы и кастомные вёдра
Когда встроенных ключей мало, подключается selectors framework. selector = 'ip', selector = 'from', selector = 'rcpt' — базовые. Часто нужен лимит не по конкретному хосту, а по подсети /24 (спамеры ротируют IP внутри одного блока). Маску даёт трансформ ipmask:
Для совсем нестандартных ключей — Lua через custom_keywords, например связка «домен отправителя + страна IP». Это уровень, куда лезут, когда стандартного to_ip объективно не хватает; в 90% случаев до него не доходит.
Исключения, иначе поломаете почту
Лимиты без исключений однажды отрежут что-то важное. Обязательный минимум:
`whitelisted_rcpts = "postmaster,mailer-daemon"` — RFC 5321 требует, чтобы postmaster@ был доступен всегда. Залимитируете — нарушите стандарт и потеряете abuse/postmaster-переписку.
`whitelisted_ip = "192.168.0.0/16"` — свои relay, backup-MX, внутренние сервисы не должны упираться в лимиты.
`max_rcpt = 5` — если у письма получателей больше порога, проверка не применяется. Легитимная рассылка на 50 адресов иначе мгновенно переполнит ведро to.
пропуск доверенной почты через модуль `settings` — например, не лимитировать сообщения с валидной DKIM-подписью вашего домена: источник у них уже подтверждён. Делается это не опцией ratelimit, а правилом в settings, которое отключает модуль для таких писем.
Отладка и тюнинг под реальный трафик
На время обкатки держите info = true — модуль пишет в rspamd.log состояние каждого ведра (уровень, burst, rate). Базовый набор команд:
bash
# синтаксис конфига не сломан
rspamadm configtest
# прогнать письмо через фильтры и увидеть вердикт
rspamc < message.eml
# проверить, что submission реально отдаёт 451 при переполнении
swaks --to [email protected] --server 127.0.0.1:587 --auth --auth-user [email protected]
# найти срабатывания ratelimit в логе
grep -i ratelimit /var/log/rspamd/rspamd.log
Важный нюанс про наблюдаемость. По умолчанию при переполнении модуль сразу применяет действие soft reject — в истории и в выводе rspamc вы видите не отдельный символ, а само действие с сообщением Rate limit exceeded. Отдельных символов вида RATELIMIT_TO по умолчанию нет. Если нужно обкатать лимиты, не блокируя почту, задайте опцию symbol — тогда вместо действия вставится символ, видимый в WebUI и истории, а письмо пройдёт дальше:
hcl
# режим наблюдения: символ вместо блокировки, снять в бою
symbol = "RATELIMIT";
Пороги подбирают не наугад. Снимите перцентили реального потока — p95 и p99 писем в единицу времени по нужному ключу из логов — и ставьте burst чуть выше p99 (чтобы легитимный пик проходил), а rate — по устойчивому среднему. Ведро, настроенное по факту трафика, ловит аномалию за минуты; ведро «на глаз» либо режет живую почту, либо пропускает атаку.
Чеклист боевого развёртывания
Redis поднят и вынесен на отдельную db (db = "3"), не шарится с greylist вслепую.
Лимит на `user` жёстче лимита на IP — это единственная защита от взломанного SASL-аккаунта.
`postmaster` и `mailer-daemon` в `whitelisted_rcpts` — иначе нарушаете RFC.
`max_rcpt` выставлен (по умолчанию 5), чтобы легитимные рассылки не переполняли ведро to.
`info = true` включён на период обкатки — без логов вёдер тюнинг вслепую.
Soft reject проверен через `swaks`/telnet — убедитесь, что при переполнении реально приходит 451, а не тихий пропуск.
Настроен алерт на переполнения ratelimit в логах (или на символ RATELIMIT, если гоняете модуль в режиме наблюдения) — это ранний сигнал о компрометации, за часы до tempfail от провайдеров.
Пороги сняты с `p99` реального трафика, а не выдуманы.
Протестирован rollback — закомментировать блок и systemctl reload rspamd должно мгновенно снять лимиты, если что-то пошло не так.