Короткий ответ
Brute force — много попыток пароля к одному URL (/wp-login.php, /wp-admin, HTTP basic, кастомный /login). Режьте частоту с IP, не «любой 404». На nginx: limit_req по $binary_remote_addr с пустым ключом для 10.0.20.0/24. Дополнительно fail2ban по 401/POST, ignoreip на офис и мониторинг. Сайт site.example на 10.0.20.40. Не баньте /16 интернета и не ставьте bantime = -1 без процедуры разбана.
Это не замена 2FA: админка. Массовые сканеры URI — боты.
Симптомы и как отличить
- access.log: POST
/wp-login.phpс 200/302 (ошибка WP тоже 200 с телом «неверный пароль»). - Basic auth: 401 пачками.
- После fail2ban сотрудники 403, боты тоже — слишком широкий бан.
| Паттерн | Мера |
|---|---|
| Один IP, много login | limit_req + ban IP |
| Боты по всей витрине | лимит PHP, не только login |
| Один логин с разных IP | 2FA, credential stuffing — не только rate |
Возможные причины
- Весь офис за NAT одного IP —
rate=1r/sмало для 30 человек. - Нет
ignoreip. - CDN: все запросы с IP edge.
limit_reqнаlocation /вместо login.- fail2ban
maxretry3 за 10 минут на NAT.
Диагностика
sudo awk '$6 ~ /POST/ && $7 ~ /wp-login/ {print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -nr | head
sudo grep wp-login /var/log/nginx/access.log | awk '{print $1}' | wc -l
sudo fail2ban-client status 2>/dev/null
sudo fail2ban-client status wordpress 2>/dev/nullИмя jail может быть другим. Смотрите /etc/fail2ban/jail.d/.
Проверьте, откуда ходит мониторинг (Uptime) — его IP в белый список.
За CDN:
sudo nginx -T 2>/dev/null | grep -i real_ipБез real_ip лимит ключует IP CDN.
Решение
sudo tar -C /etc -czf /root/nginx-ratelimit-$(date +%F).tar.gz nginxСценарий A. nginx limit_req только на login
geo $limit_login {
default 1;
10.0.20.0/24 0;
}
map $limit_login $login_key {
0 "";
1 $binary_remote_addr;
}
limit_req_zone $login_key zone=login:10m rate=3r/m;
limit_req_status 429;rate=3r/m — три попытки в минуту с чужого IP. Офис не ключуется.
location = /wp-login.php {
limit_req zone=login burst=5 nodelay;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}Подберите burst: легитимный «ошибся пароль 2 раза» не должен ловить 429, бот на 100 POST — должен.
nginx -t && reload. Ответ 429 — норма. Можно кастомную error_page 429.
Сценарий B. HTTP basic на служебном location
location /private/ {
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/.htpasswd;
limit_req zone=login burst=3 nodelay;
}Файл паролей вне webroot, chmod 640, владелец root:www-data.
Сценарий C. fail2ban без поражения NAT
В jail.d/wordpress.local:
[wordpress]
enabled = true
filter = wordpress
logpath = /var/log/nginx/access.log
maxretry = 20
findtime = 10m
bantime = 1h
ignoreip = 127.0.0.1/8 10.0.20.0/24
action = %(action_)sФильтр должен матчить POST wp-login. Не копируйте фильтр, который банит любой 404. banaction = ваш firewall (ufw/nft), не dummy. Проверка:
sudo fail2ban-client reload
sudo fail2ban-client status wordpressРазбан сотрудника:
sudo fail2ban-client set wordpress unbanip 10.0.20.55Не fail2ban-client stop навсегда.
Сценарий D. Apache
mod_authn + mod_ratelimit / fail2ban по error.log 401. Require ip на login — см. админку.
Сценарий E. Приложение (WordPress)
Плагины limit login: как доп. слой, но при падении PHP они не работают. nginx режет до FPM — очередь pm не растёт от брутфорса.
Сценарий F. Статус 200 на неверный пароль WP
fail2ban filter вида status = 401 не сработает на стандартный wp-login. Нужен regex на POST /wp-login.php с порогом по количеству, либо плагин, который отдаёт 401/429. Надёжнее nginx limit_req до PHP: бот не дойдёт до wp-includes.
За CDN включите real_ip_header CF-Connecting-IP (или аналог) только для set_real_ip_from диапазонов этого CDN. Иначе клиент подделает заголовок и обойдёт лимит / попадёт в чужой бан.
Мониторинг синтетики, который POST'ит логин каждые 30 с, либо исключите IP, либо переведите проверку на GET /wp-login.php без POST. Иначе вы сами забаните зонд.
limit_req ключ $binary_remote_addr на IPv6 /64 ботнета слаб — для login всё же лучше, чем ничего; 2FA закрывает stuffing с разных IP.
Как проверить, что проблема устранена
С тестового IP вне офиса (VPS):
for i in 1 2 3 4 5 6; do
curl -s -o /dev/null -w '%{http_code}\n' -X POST --resolve site.example:443:10.0.20.40 \
https://site.example/wp-login.php
doneОжидание: после порога 429 (nginx) или бан fail2ban (timeout). С машины в 10.0.20.0/24 те же POST не банятся. Легитимный вход с 2FA проходит. Мониторинг не в бане.
Не используйте реальные пароли в цикле POST — только факт кода ответа.
Если не помогло
- 200 на все POST:
limit_reqне в том server/location, PHP обрабатывает. - IPv6 боты, вы ключуете только v4 —
listen [::]и$binary_remote_addrобычно покрывает; проверьте, что нет отдельного server без лимита. - HTTP/3/CDN: лимит на origin видит один IP.
- Credential stuffing медленный (1/мин с тысяч IP) — 2FA и отказ от простых паролей, rate per IP не спасёт.
Профилактика
- Сразу с запуска сайта: zone login + ignore офиса.
- Алерт на число POST wp-login.
- Не светить basic auth на том же порту без лимита.
- После инцидента плагина — ротация.
FAQ
429 vs 503 для limit_req?
429 точнее по смыслу. 503 путают с «сайт лёг». limit_req_status 429.
Можно ли rate=10r/s на login?
Слишком щедро для пароля, слабо для бота. Для login — единицы в минуту.
fail2ban и docker NPM
Логи в контейнере: пробросьте access.log или используйте limit в NPM Advanced. Два слоя ок, разные ignoreip.
CAPTCHA вместо rate limit?
Можно поверх. Без лимита PHP всё равно грузят до показа капчи.
limit_conn 1 на login
Сломает параллельные вкладки. Для login важнее req, не conn.