Короткий ответ

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, много loginlimit_req + ban IP
Боты по всей витринелимит PHP, не только login
Один логин с разных IP2FA, credential stuffing — не только rate

Возможные причины

  1. Весь офис за NAT одного IP — rate=1r/s мало для 30 человек.
  2. Нет ignoreip.
  3. CDN: все запросы с IP edge.
  4. limit_req на location / вместо login.
  5. fail2ban maxretry 3 за 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.