Обмеження швидкості в rspamd: ліміти на користувача та IP проти скомпрометованих акаунтів
Скомпрометований SMTP-логін на порту 587 псує репутацію IP для всіх клієнтів за лічені хвилини. Розбираємо, як leaky-bucket ліміти rspamd на користувача, IP та пару користувач+отримувач обмежують радіус ураження — з робочими конфігами, інспекцією Redis і дисципліною розкотки «спершу поміряти, потім різати».
EvilMail Team15 липня 2026 р.11 хв читання
О 03:40 клієнтський акаунт із давно витеклим паролем автентифікувався на submission-порту 587 і почав рівномірно слати приблизно по три листи на секунду. Ніякого сплеску, який впав би в очі — стабільні ~180 повідомлень за хвилину крізь ваш MSA. За двадцять хвилин це вже 3400 листів, ваш вихідний IP у CBL, а delivery для решти клієнтів на цьому ж /24 просів. До ранкової кави ви ще не дійшли, а тікет у Spamhaus уже неминучий.
Питання тут не «як зловити спам за контентом» — Bayes і фаззі-хеші наздоженуть цю розсилку хвилин за десять, коли набереться статистика, і це буде надто пізно. Питання інше: як обмежити пропускну здатність одного акаунта чи IP, поки система ще не зрозуміла, що це злам. Rate limiting — не «ще один антиспам-модуль», а запобіжник, що обмежує радіус ураження. Доки зловмисник встигне розіслати 20–40 листів, відро вже переповнене, MSA віддає 451, а не 250, і черга відправника тримає решту, поки ви прокидаєтесь.
Чому Postfix anvil тут — груба палиця
Перше, до чого тягнеться рука, — smtpd_client_message_rate_limit у Postfix. Проблема в тому, що anvil рахує по IP клієнтського з'єднання, а не по SASL-користувачу. Клієнт за CGNAT, мобільний оператор, офіс за одним NAT — усі вони зливаються в один лічильник, і ви або ріжете легітимних людей, або тримаєте ліміт настільки високим, що він марний. До того ж це лічильник із фіксованим вікном: на стику двох вікон акаунт легально видає 2x від номінальної швидкості, бо лічильник скидається рівно посередині сплеску.
Rate limiting у rspamd: ліміти на користувача й IP проти зламаних акаунтів — EvilMail Blog
rspamd отримує від Postfix ім'я SASL-користувача (через milter-макрос авторизації), тому будує ключ по самому логіну, незалежно від того, з якого IP клієнт зайшов. І модель у нього не лічильник, а протікаюче відро.
Відро, а не лічильник
Leaky-bucket описується двома параметрами: burst — місткість відра, і rate — швидкість, з якою воно спорожнюється. Кожен лист доливає одиницю. Відро тече з постійною швидкістю. Поки приплив не перевищує стале витікання, рівень тримається низько і листи проходять. Щойно приплив стабільно вищий за rate — рівень повзе до краю, і на переповненні спрацьовує дія.
Це рівно те, що потрібно для розрізнення легітимного від зламаного. Жива людина шле пачкою: розіслала 15 листів по контактах вранці — і тиша. Burst 20 таку пачку пропускає без питань. Скомпрометований акаунт натомість жене рівний потік годинами — і саме цей рівний потік упирається в rate. Лічильник із фіксованим вікном такого розрізнення не дає в принципі.
Стан кожного відра rspamd тримає в Redis — тому модуль ratelimit обов'язково потребує Redis і без нього просто не працює. Ключ будується окремо на кожен селектор: на кожного користувача, кожен IP, кожну пару — своє відро зі своїм рівнем.
Селектори й ключі: користувач, IP, пара
Модуль будує ключ через selector. Є готові преднайдені відра — to, to_ip, to_ip_asn, bounce_to, bounce_to_ip — але для MSA цікаві кастомні. Головний селектор тут — user: SASL-логін після авторизації. Нормалізуйте його через user.lower, щоб Bob@ і bob@ не роз'їхались на два відра.
Селектори композуються через ;. Ключ user.lower;rcpt дає ліміт «скільки листів одному конкретному отримувачу від одного користувача» — це антирозсилковий шар. Логіка багатошарова: зламаний акаунт, що шле по одному листу тисячі різних адрес, вузьким user+rcpt ви не спіймаєте, зате широким user.lower — легко; а той, хто довбить одну адресу, впаде на user+rcpt. Перевірити, що селектор віддає саме той ключ, який ви задумали, найпростіше по факту: символ RATELIMIT_CHECK у історії rspamd показує ім'я відра й хеш ключа, який спрацював, — це остаточна істина, а не ваші припущення про формат.
Стратегія — багатошаровість: широке відро на user як денна стеля, вузьке на user+rcpt проти повторів, і страхувальне на ip — на випадок, якщо кілька акаунтів компрометовані з однієї машини зловмисника.
Конфігурація: багаторівневі відра
Уся конфігурація живе в /etc/rspamd/local.d/ratelimit.conf. Ключова конструкція — масив відер під одним лімітом: лист проходить лише тоді, коли всі відра в масиві не переповнені. Це дозволяє поставити і хвилинну стелю проти сплеску, і денну проти повільного зливу. Redis-параметри модуль бере власні, тож заразом виносимо стан відер на окрему логічну БД, щоб flush кешу Bayes/fuzzy не зносив ліміти:
ucl
# з'єднання й ізольована БД саме для ratelimit
servers = "127.0.0.1:6379";
dbname = "2";
# за потреби: password = "...";
rates {
# Хвилинне + денне відро на користувача.
# Обидва мусять пропустити, інакше soft reject.
user_flood {
selector = 'user.lower';
bucket = [
{ burst = 20; rate = "20 / 1m"; },
{ burst = 500; rate = "1000 / 1d"; }
];
}
# Антирозсилка: скільки одному отримувачу від одного юзера.
user_rcpt {
selector = 'user.lower;rcpt';
bucket = { burst = 5; rate = "10 / 1h"; };
}
# Страхувальне відро на IP джерела.
msa_ip {
selector = 'ip';
bucket = { burst = 200; rate = "300 / 1m"; };
}
}
whitelisted_rcpts = "postmaster,mailer-daemon";
max_rcpt = 20;
message = "Rate limit exceeded, try later (compromised? contact support)";
Формат rate — "N / t", де t це 1s, 1m, 1h або 1d. whitelisted_rcpts виключає службові адреси, щоб баунси й повідомлення postmaster не жерли ліміт. max_rcpt = 20 пропускає повз ratelimit листи з великою кількістю адресатів — це навмисно, щоб не ламати легальні розсилки по списку; такі листи мають ловитися іншими політиками, не швидкістю.
Глобальний /etc/rspamd/local.d/redis.conf лишається для решти модулів (Bayes, fuzzy, кеш) — але його dbname спільний для всіх, тому власна БД у ratelimit.conf вище і є тим, що ізолює стан відер. Після будь-яких правок — обов'язково валідація перед reload, ніколи наосліп:
bash
rspamadm configtest && systemctl reload rspamd
Обмежувати лише автентифікованих
Найважливіше правило, яке легко проґавити: вхідну пошту цими лімітами чіпати не можна. Хороша новина в тому, що відра на user і user.lower;rcpt роблять це самі: у неавтентифікованого inbound-трафіку немає SASL-логіна, селектор user повертає порожньо, і rspamd таке відро просто не створює. Небезпечне лише відро на чистий ip — воно однаково рахує і submission, і inbound, а легальний MX під час розсилки від великого відправника чи ретраю черги дасть цілком нормальний пік, який ви заріжете й почнете відбивати листи, що мали прийняти.
Тому ip-відро прив'язуйте до факту авторизації через модуль settings у /etc/rspamd/local.d/settings.conf:
Так відра вмикаються тільки на submission-трафіку, де є SASL-логін. Альтернатива, якщо у вас роздільні worker-proxy для 587 і inbound 25, — окремий MSA-інстанс і apply за портом.
Розкотка без сюрпризів: спершу поміряти, потім різати
Класичний антипатерн — увімкнути пороги, взяті з голови, і в чорну п'ятницю зарізати найактивнішого клієнта, який має повне право розіслати 300 підтверджень замовлень за годину. Пороги беруться не зі стелі, а з даних.
Важлива деталь: у ratelimit немає режиму «лише позначати» — щойно відро переповнюється, модуль одразу віддає soft reject. Тому чесний dry-run робиться навпаки: почніть із навмисно щедрих лімітів, за яких жоден легітимний клієнт не впреться в стелю, і кілька днів дивіться, наскільки заповнюються відра (поле b у Redis) та які реальні листи-за-хвилину на акаунт у логах. Зніміть p95 і p99, і лише тоді опускайте пороги до «реальна стеля плюс запас». Різниця між «ми думали, клієнти шлють по 20/хв» і «p99 у нас 47/хв» — це різниця між робочим лімітом і потоком гнівних тікетів.
Динамічні ліміти й стан у Redis
rspamd вміє автоматично звужувати чи розширювати відро за spam/ham історією ключа. Множники зберігаються прямо в Redis у полях dr (rate) і db (burst), а стелю їхнього росту задають параметри max_rate_mult, max_bucket_mult і dynamic_rate_ttl. Акаунт із чистою історією отримує ширше відро; той, що вже наловив спам-вердиктів, автоматично звужується ще до того, як ви руками втрутитесь.
Стан відра завжди можна прочитати напряму. Ключі несуть ratelimit-префікс, читати їх краще через SCAN, а не блокуючий KEYS (точний префікс залежить від версії — знайдіть його шаблоном):
Поля в hash: b — поточний рівень burst (наскільки повне відро), l — timestamp останнього оновлення, dr/db — динамічні множники rate і burst, p — pending. Якщо b впритул до заданого burst — відро на межі, наступні листи впадуть у reject. TTL rspamd виставляє сам; коли відро давно порожнє, ключ просто протухає.
Перевірка відра відбувається у prefilter-фазі (RATELIMIT_CHECK), а оновлення рівня — в idempotent-фазі (RATELIMIT_UPDATE): лист спершу перевіряється проти поточного стану, а вже після обробки доливає відро. Тому спам-вердикт не рахується двічі.
Коли відро переповнилось: алерти й автоблок
Спрацювання ratelimit на MSA — це не рядова відмова, а сигнал компрометації. Легітимний клієнт впирається в стелю раз на квартал; акаунт, що б'ється об 451 десятки разів за хвилину, — майже напевно зламаний. Тому reject треба зав'язати на реакцію.
На практиці це грепання логу й лічильник спрацювань на вікно:
bash
journalctl -u rspamd -f | grep -i ratelimit
По N спрацюваннях за вікно від одного SASL-логіна — fail2ban jail або власний скрипт, що вимикає акаунт у Dovecot чи в базі й кидає алерт черговому. Важлива деталь: soft reject 451 тримає листи в черзі відправника, а не викидає їх, тому поки ви реагуєте, легітимна пошта того ж акаунта (якщо це false positive) не губиться — вона доїде на ретраї, коли відро стече. Це принципова різниця з 550: помилившись із порогом, ви нікому нічого не втратите.
Чеклист впровадження
Redis піднятий, ratelimit на окремій `dbname` — flush Bayes не має зносити стан відер.
`ip`-відро лише на `authenticated` через модуль settings; user-відра самі не чіпають inbound, бо без SASL-логіна селектор порожній.
Масив відер: хвилинне проти сплеску + денне проти повільного зливу, обидва мусять пропустити.
Whitelist службових адрес (postmaster, mailer-daemon) і max_rcpt, щоб не ламати легальні розсилки.
Спершу щедрі ліміти й вимір p95/p99 з логів і поля b у Redis, і лише потім затягуйте пороги — dry-run у ratelimit робиться саме так.
Пороги з реальних перцентилів + запас, а не з голови.
Алерт на спрацювання зав'язаний на автоблок (fail2ban / вимкнення SASL), бо reject на MSA = сигнал компрометації.
`rspamadm configtest` у деплой-пайплайні перед кожним reload.
**Моніторинг ключів RL_* у Grafana** — рівень b близько до burst попереджає про проблему раніше за скаргу клієнта.