<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>wp-login &#8211; REMADMIN</title>
	<atom:link href="https://remadmin.com/tags/wp-login/feed/" rel="self" type="application/rss+xml" />
	<link>https://remadmin.com</link>
	<description>Удалённый системный администратор</description>
	<lastBuildDate>Sun, 06 Sep 2026 12:57:32 +0000</lastBuildDate>
	<language>ru-RU</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1</generator>
	<item>
		<title>Nginx: лимиты на wp-login и xmlrpc без отстрела нормальных пользователей</title>
		<link>https://remadmin.com/blog/vebmasteru/nginx-limity-na-wp-login-i-xmlrpc-bez-otstrela-normalnyh-polzovatelej/</link>
					<comments>https://remadmin.com/blog/vebmasteru/nginx-limity-na-wp-login-i-xmlrpc-bez-otstrela-normalnyh-polzovatelej/#respond</comments>
		
		<dc:creator><![CDATA[Cursor Blog]]></dc:creator>
		<pubDate>Sun, 06 Sep 2026 12:57:32 +0000</pubDate>
				<category><![CDATA[Вебмастеру]]></category>
		<category><![CDATA[Nginx]]></category>
		<category><![CDATA[Wordpress]]></category>
		<category><![CDATA[wp-login]]></category>
		<category><![CDATA[xmlrpc]]></category>
		<category><![CDATA[безопасность]]></category>
		<category><![CDATA[спам]]></category>
		<guid isPermaLink="false">https://remadmin.com/?p=7407</guid>

					<description><![CDATA[Как я режу брутфорс на wp-login.php и xmlrpc.php через nginx limit_req, не закрывая офис за NAT и не превращая отказ в 503 по всему PHP. Отдельные зоны, POST-only, real_ip и почему system.multicall лимитер не видит.]]></description>
										<content:encoded><![CDATA[<p>Типичная заявка: «сайт лежит, nginx отдаёт 503, в access.log сплошной wp-login.php». Открываю лог — не DDoS на главную, а брутфорс. Сотни POST в минуту на <code>wp-login.php</code> и <code>xmlrpc.php</code> с пары десятков адресов. Человек уже воткнул <code>limit_req</code> на весь <code>location ~ \.php$</code> «на всякий случай». Через час звонит редактор из офиса: не логинится, превью в админке крутится, мобильное приложение WordPress отвалилось.</p>
<p>Лимит сработал. Просто он отстрелил своих вместе с ботами. Офис сидит за одним белым IP, xmlrpc дергает приложение каждые несколько секунд, а общий php-location обслуживает и логин, и <code>admin-ajax.php</code>, и оформление заказа.</p>
<p>Ниже — как я режу именно логин и xmlrpc, оставляя сайт живым, и где <code>limit_req</code> бесполезен, сколько его ни крути. Без «поставьте плагин лимита логинов» и без слепого <code>deny all</code> на xmlrpc, пока не ясно, кто им пользуется.</p>
<h5>Что бьют на самом деле, и почему это две разные двери</h5>
<p>На типичном WordPress без WAF смотрят три URL:</p>
<ul>
<li><code>/wp-login.php</code> — форма входа. GET рисует форму, POST проверяет пароль.</li>
<li><code>/xmlrpc.php</code> — старый API. С него удобно перебирать пароли пачками через <code>system.multicall</code> и дёргать pingback.</li>
<li>Иногда <code>/wp-admin/</code> и REST <code>/wp-json/</code> — это уже другие истории, их в ту же зону, что логин, я не кладу.</li>
</ul>
<p>GET на логин — не атака. Человек открыл форму, браузер подтянул редирект, плагин 2FA нарисовал второй шаг. Если лимитировать любой метод, первый же заход «съедает» burst, а POST с паролем уже ловит 429. Поэтому ключ зоны я завязываю на POST, не на URL целиком.</p>
<p>xmlrpc почти всегда POST. Закрывать его наглухо можно, если им никто не пользуется: нет официального приложения WP, нет Jetpack, нет старых клиентов публикации. Если приложение нужно — <code>return 403</code> хуже брутфорса: свои отвалятся сразу, боты просто перейдут на <code>wp-login.php</code>.</p>
<h5>Почему общий limit_req на php убивает нормальных людей</h5>
<p><code>admin-ajax.php</code> на живом сайте стучит постоянно: пульс админки, автосохранение записи, корзина, фильтры каталога. Один редактор за минуту легко делает десятки POST. Посадили его в зону <code>1r/s burst=3</code> вместе с логином — получите «сайт тормозит», хотя CPU спокойный, а в логе 503 на ajax.</p>
<p>Вторая ловушка — общий IP. Офис, коворкинг, 4G-оператор с CGNAT, гостиница. Двадцать человек за одним адресом. Лимит «один запрос в секунду на IP» для брутфорса с ботнета ещё терпим, для офиса это коллективный бан после трёх неудачных паролей подряд.</p>
<p>Третья — статус по умолчанию. Nginx на превышение отдаёт 503. Мониторинг орёт «сайт мёртв», человек лезет в php-fpm и диск. Это не падение бэкенда, это отказ лимитера. Ставлю <code>limit_req_status 429</code> и в логах сразу видно, что именно отсекли, а не «бэкенд не отвечает».</p>
<h5>Две зоны, не одна</h5>
<p>Зоны объявляю в <code>http</code>, не в <code>server</code>. Размер <code>10m</code> для <code>$binary_remote_addr</code> — десятки тысяч IP, на обычный сайт с головой. Rate считаю отдельно:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="nginx"># http { ... }
limit_req_zone $limit_key_wplogin zone=wplogin:10m rate=6r/m;
limit_req_zone $limit_key_xmlrpc  zone=xmlrpc:10m  rate=1r/s;
limit_req_status 429;
limit_req_log_level warn;</pre>
<p>Шесть POST в минуту на логин — человек с кривым паролем ещё влезет, словарь на тысячи попыток уже нет. xmlrpc чуть свободнее по частоте HTTP-запросов: живое приложение иногда стучит пачкой. Это не дыра — дыра в xmlrpc не частота, а толстое тело, про это ниже.</p>
<p>Ключ пустой строкой означает «этого запроса в зоне нет». Так я выключаю лимит для GET и для белого списка.</p>
<pre class="EnlighterJSRAW" data-enlighter-language="nginx">map $request_method $limit_key_wplogin {
    POST $binary_remote_addr;
    default "";
}

# xmlrpc почти весь POST; GET на него мне не жалко тоже резать
map $request_method $limit_key_xmlrpc {
    default $binary_remote_addr;
}</pre>
<p>Если офис сидит на постоянном адресе, вычитаю его из ключа через <code>geo</code>. В примере — документационный диапазон, не чей-то боевой:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="nginx">geo $offnet {
    default 0;
    203.0.113.0/24 1;   # офис, подставьте свой
}

map $offnet $limit_key_wplogin {
    1      "";
    default $limit_wplogin_ip;
}

map $request_method $limit_wplogin_ip {
    POST $binary_remote_addr;
    default "";
}</pre>
<p>Два <code>map</code> на одну переменную nginx не даст — ключ собираю в одном месте. Рабочий вариант без сюрпризов:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="nginx">geo $is_office {
    default 0;
    203.0.113.0/24 1;
}

map "$is_office:$request_method" $limit_key_wplogin {
    "1:POST" "";
    "0:POST" $binary_remote_addr;
    default  "";
}

map $is_office $limit_key_xmlrpc {
    1       "";
    default $binary_remote_addr;
}</pre>
<p>Белый список — не «интернет офиса целиком». Только тот префикс, с которого реально заходят в админку. Домашний динамический IP туда не тащу: через месяц он уедет соседу.</p>
<h5>location: только эти два файла, тот же php, что у остальных</h5>
<p><code>location =</code> с точным именем бьёт точнее regex. Обработчик php не выдумываю — копирую то, что уже работает для <code>*.php</code>. На Debian это часто <code>snippets/fastcgi-php.conf</code> плюс ваш <code>fastcgi_pass</code>. Сокет не копируйте из чужой статьи: у вас может быть другой пул.</p>
<pre class="EnlighterJSRAW" data-enlighter-language="nginx">server {
    # ...

    location = /wp-login.php {
        limit_req zone=wplogin burst=4 nodelay;
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php-fpm.sock;  # как у вас в общем php
    }

    location = /xmlrpc.php {
        limit_req zone=xmlrpc burst=8 nodelay;
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php-fpm.sock;
    }

    # общий php — БЕЗ limit_req этих зон
    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php-fpm.sock;
    }
}</pre>
<p><code>burst=4 nodelay</code> на логине: четыре лишних POST проходят сразу, дальше 429, без очереди. Без <code>nodelay</code> nginx начинает задерживать запросы — форма «думает» секундами, человек жмёт ещё раз, вы сами себе множите POST. Для логина я предпочитаю жёсткий отказ, не задержку.</p>
<p>На xmlrpc burst больше: приложение может открыть несколько соединений подряд при синхронизации. Если xmlrpc вам не нужен совсем — не лимитируйте, закройте:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="nginx">location = /xmlrpc.php {
    return 403;
}</pre>
<p>Проверяю необходимость просто: <code>grep xmlrpc access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head</code> и user-agent. Если неделю стучат только словари и <code>Hello world</code> — закрываю. Если есть <code>wp-iphone</code> / <code>wp-android</code> с адресов редакции — оставляю зону.</p>
<h5>Настоящий IP, иначе режете Cloudflare, а не бота</h5>
<p><code>$binary_remote_addr</code> — это TCP-пир. Стоит сайт за Cloudflare, nginx-прокси или балансировщиком хостера — пир один на всех. Лимит превращается в глобальный рубильник: первый бот выжигает burst, посетители с того же эджа ловят 429.</p>
<p>Смотрю, откуда приходит запрос:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="generic">log_format debugip '$remote_addr cf="$http_cf_connecting_ip" xff="$http_x_forwarded_for"';</pre>
<p>Дальше <code>set_real_ip_from</code> на префиксы своего прокси и <code>real_ip_header</code> — тот заголовок, которому вы доверяете. Не доверяю <code>X-Forwarded-For</code> с интернета без фильтра: его подставляет кто угодно. Для Cloudflare — их диапазоны и <code>CF-Connecting-IP</code>. Для своего nginx-прокси — только его адрес в <code>set_real_ip_from</code>.</p>
<p>Пока real_ip не настроен, <code>limit_req</code> на логин я не включаю в боевой <code>server</code>. Иначе «защита» — это лотерея, кто первый постучал в этот воркер.</p>
<h5>Где лимит бесполезен: system.multicall</h5>
<p>Брутфорс по xmlrpc часто укладывается в <em>один</em> HTTP POST. Внутри <code>system.multicall</code> — сотни <code>wp.getUsersBlogs</code> с разными паролями. Для nginx это один запрос. Зона <code>1r/s</code> его пропускает, php-fpm честно пережёвывает пачку, в <code>auth.log</code> WordPress — сотни неудачных логинов, в access.log — одна строка 200.</p>
<p>Поэтому «я поставил rate limit, xmlrpc больше не брутфорсят» — самообман, если multicall жив. Nginx <code>$request_body</code> в <code>if</code> на практике пустой: тело ещё не прочитано в той фазе, конфиг из гугла с <code>if ($request_body ~ multicall)</code> просто не срабатывает. Я это не рисую как рабочую схему.</p>
<p>Что работает без магии:</p>
<ul>
<li>xmlrpc не нужен — <code>return 403</code> на location, плюс фильтр в WordPress на всякий случай;</li>
<li>нужен только pingback — всё равно спорно, pingback сам по себе источник шума; чаще его выключают;</li>
<li>нужно приложение — оставляю xmlrpc под <code>limit_req</code> и отдельно отключаю multicall на стороне PHP (му-плагин на <code>xmlrpc_methods</code> выкидывает <code>system.multicall</code>), либо режу на WAF, который тело реально видит;</li>
<li>Application Passwords и REST — другая дверь, xmlrpc для них не обязателен. Если мобильный вход уже через REST, xmlrpc я закрываю, не лимитирую.</li>
</ul>
<p>Лимит по запросам и разбор методов xmlrpc — два разных слоя. Первый без второго на этой двери недоделан.</p>
<h5>Как проверить, что своих не задело</h5>
<p>Не тестирую с того же ноутбука, с которого админю. Иначе сам себя выкину и буду чинить по аварийной консоли. С другого адреса, лучше с маленького VPS:</p>
<pre class="EnlighterJSRAW" data-enlighter-language="generic"># форма должна открываться сколько угодно
for i in $(seq 1 20); do curl -s -o /dev/null -w "%{http_code}\n" https://example.com/wp-login.php; done

# POST — должны пойти 429 после burst
for i in $(seq 1 20); do
  curl -s -o /dev/null -w "%{http_code}\n" -X POST \
    -d "log=nosuchuser&amp;pwd=wrong&amp;wp-submit=Log+In" \
    https://example.com/wp-login.php
done

# ajax и главная не в этой зоне
for i in $(seq 1 30); do
  curl -s -o /dev/null -w "%{http_code}\n" -X POST https://example.com/wp-admin/admin-ajax.php
done</pre>
<p>Ожидание: GET логина — сплошные 200 (или 302, если уже есть редирект). POST — несколько 200/302, потом 429, не 503. ajax — без 429. Главная и карточки товара — без 429.</p>
<p>Потом то же с офисного IP, если он в <code>geo</code>: POST не должен упираться в 429, иначе белый список не попал в ключ. Частая ошибка — <code>geo</code> по <code>$remote_addr</code> до <code>real_ip</code>, и офис виден как адрес Cloudflare.</p>
<p>В логе ищу не «много 404», а явно статус и зону. С <code>limit_req_log_level warn</code> в error.log будет <code>limiting requests, excess: ... zone=wplogin</code>. Если зоны в логе нет, а 503 есть — вы смотрите не на лимитер, а на php-fpm/upstream.</p>
<h5>Что я не кладу в эти зоны</h5>
<ul>
<li><code>admin-ajax.php</code>, <code>async-upload.php</code>, cron <code>wp-cron.php</code> — это не брутфорс логина.</li>
<li>REST <code>/wp-json/</code> целиком. Там и легитимный редактор, и Application Passwords. Если резать — отдельные location на конкретные маршруты, не оптом.</li>
<li><code>wp-admin/</code> GET статики и <code>admin-post.php</code> с авторизованной сессией.</li>
<li>Статику и <code>wp-content/uploads</code> — к логину отношения не имеет.</li>
</ul>
<p>Отдельно: плагины «кастомной страницы входа» часто POST-ят не на <code>/wp-login.php</code>, а на <code>/</code> или на <code>/my-account/</code>. Тогда мой location логин не защищает, зато WooCommerce-чекаут внезапно оказывается под лимитом, если кто-то прикрутил зону на <code>location /</code>. Смотрю в access.log реальный URI при неудачном входе, не название плагина на коробке.</p>
<h5>fail2ban рядом, не вместо</h5>
<p>Nginx лимитер не помнит «этот IP вчера перебирал пароли». Он держит короткое окно. fail2ban по error.log с <code>zone=wplogin</code> или по auth-логу WordPress закрывает упёртых на часы. Но jail, который банит после трёх 429, снова отстрелит офис. Либо офис в ignoreip, либо порог заметно выше человеческого «забыл пароль, нажал пять раз».</p>
<p>Я не баняю 429 с ajax и не баняю 404 на <code>xmlrpc.php</code>, если сами его закрыли 403: боты будут стучать вечно, jail раздует ipset, а толку ноль — они и так получают отказ.</p>
<h5>Что оставляю на обычном сайте</h5>
<p>Рабочий минимум, которым я заканчиваю, если это один WordPress на VPS без приложения:</p>
<ul>
<li>real_ip настроен, в логе виден клиент, не эдж;</li>
<li><code>wp-login.php</code> — POST-only зона ~6r/m, burst 4 nodelay, статус 429;</li>
<li><code>xmlrpc.php</code> — 403, если за неделю нет своих клиентов;</li>
<li>если клиент есть — зона 1r/s + выключенный <code>system.multicall</code> в PHP;</li>
<li>офисный префикс в <code>geo</code>, не «весь провайдер»;</li>
<li>общий php-location без этих зон;</li>
<li>проверка циклом curl с чужого IP и глазами error.log, что падает зона, а не 503 от fpm.</li>
</ul>
<p>Плагин лимита логинов в WordPress я не считаю заменой: он срабатывает уже внутри PHP, после того как fpm взял воркер. Под словарём это дороже, чем отказ на nginx. Плагин имеет смысл как второй слой (капча, временная блокировка учётки), не как единственный.</p>
<p>Если после включения 429 сыпятся на живых редакторов — не поднимаю rate «пока не пройдёт». Сначала смотрю, какой URI и какой IP. Почти всегда это либо общий php-location, либо NAT офиса, либо xmlrpc приложения, которое я забыл, что оно существует.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://remadmin.com/blog/vebmasteru/nginx-limity-na-wp-login-i-xmlrpc-bez-otstrela-normalnyh-polzovatelej/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>